All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s
269 lines
9.4 KiB
Vue
269 lines
9.4 KiB
Vue
<script setup lang="ts">
|
|
import type { PlotFunction } from '~/utils/plot-expression'
|
|
import type { PlotSeries } from '~/components/PlotCanvas.vue'
|
|
|
|
/* The graphing calculator: the plane owns the whole page, the way the link
|
|
map does, and the y = f(x) inputs dock to its left edge — foldable, so
|
|
the plane can have every pixel. Every expression is compiled as it is
|
|
typed; one that does not read as a function of x goes red and simply
|
|
stops being drawn. The rows and the dock's fold are the reader's own —
|
|
kept between visits, the way the link map keeps its display switch. */
|
|
|
|
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
|
|
|
const { t } = useI18n()
|
|
|
|
interface PlotRow {
|
|
id: number
|
|
expr: string
|
|
color: string
|
|
visible: boolean
|
|
}
|
|
|
|
/* a curve wears one of eight hues, each readable on paper and on slate */
|
|
const PALETTE = [
|
|
'#e5484d', '#0091ff', '#46a758', '#f76808', '#8e4ec6', '#12a594', '#e93d82', '#3e63dd',
|
|
]
|
|
const STORAGE_KEY = 'mathew-calculator-plots'
|
|
const DOCK_KEY = 'mathew-calc-dock'
|
|
const MAX_ROWS = 12
|
|
|
|
let nextId = 1
|
|
const seedRows = (): PlotRow[] => [
|
|
{ id: nextId++, expr: 'sin(x)', color: PALETTE[0]!, visible: true },
|
|
{ id: nextId++, expr: 'x^2 / 3 - 2', color: PALETTE[1]!, visible: true },
|
|
]
|
|
|
|
const rows = ref<PlotRow[]>(seedRows())
|
|
|
|
/* one compile pass per render: the rows read alongside what they became —
|
|
a function to plot, and a reason for the ones that read wrong (an empty
|
|
field is nothing asked for, not a mistake) */
|
|
interface PlotEntry {
|
|
row: PlotRow
|
|
fn: PlotFunction | null
|
|
error: 'unknown' | 'syntax' | null
|
|
}
|
|
|
|
const entries = computed<PlotEntry[]>(() =>
|
|
rows.value.map((row) => {
|
|
const src = row.expr.trim()
|
|
if (!src) return { row, fn: null, error: null }
|
|
const result = compilePlotExpression(src)
|
|
return result.ok
|
|
? { row, fn: result.fn, error: null }
|
|
: { row, fn: null, error: result.reason === 'empty' ? null : result.reason }
|
|
})
|
|
)
|
|
|
|
const series = computed<PlotSeries[]>(() =>
|
|
entries.value.map((e) => ({
|
|
id: e.row.id,
|
|
color: e.row.color,
|
|
visible: e.row.visible && !!e.fn,
|
|
fn: e.fn,
|
|
}))
|
|
)
|
|
|
|
/* a new curve takes the first hue no other row is wearing */
|
|
function nextColor(): string {
|
|
const used = new Set(rows.value.map((r) => r.color))
|
|
return PALETTE.find((c) => !used.has(c)) ?? PALETTE[rows.value.length % PALETTE.length]!
|
|
}
|
|
|
|
function addRow() {
|
|
if (rows.value.length >= MAX_ROWS) return
|
|
rows.value.push({ id: nextId++, expr: '', color: nextColor(), visible: true })
|
|
}
|
|
|
|
function removeRow(id: number) {
|
|
rows.value = rows.value.filter((r) => r.id !== id)
|
|
}
|
|
|
|
function toggleVisible(id: number) {
|
|
const row = rows.value.find((r) => r.id === id)
|
|
if (row) row.visible = !row.visible
|
|
}
|
|
|
|
function cycleColor(id: number) {
|
|
const row = rows.value.find((r) => r.id === id)
|
|
if (!row) return
|
|
const at = PALETTE.indexOf(row.color)
|
|
row.color = PALETTE[(at + 1) % PALETTE.length]!
|
|
}
|
|
|
|
/* pressing Enter on the last row opens the next one, the way the list
|
|
invites one more function */
|
|
function onEnter(id: number) {
|
|
if (id === rows.value[rows.value.length - 1]?.id) addRow()
|
|
}
|
|
|
|
/* whether the dock stands open is the reader's own — the fold slides the
|
|
rows away and leaves the plane alone; the choice is kept between visits */
|
|
const dockOpen = ref(true)
|
|
|
|
function toggleDock() {
|
|
dockOpen.value = !dockOpen.value
|
|
try {
|
|
localStorage.setItem(DOCK_KEY, dockOpen.value ? '1' : '0')
|
|
} catch {
|
|
/* private mode etc. — the fold just won't persist */
|
|
}
|
|
}
|
|
|
|
/* the rows keep themselves between visits */
|
|
function saveRows() {
|
|
try {
|
|
localStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify(rows.value.map(({ expr, color, visible }) => ({ expr, color, visible })))
|
|
)
|
|
} catch {
|
|
/* private mode etc. — the rows just won't persist */
|
|
}
|
|
}
|
|
watch(rows, saveRows, { deep: true })
|
|
|
|
onMounted(() => {
|
|
try {
|
|
dockOpen.value = localStorage.getItem(DOCK_KEY) !== '0'
|
|
} catch {
|
|
/* storage unreadable — the dock stays open */
|
|
}
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY)
|
|
if (!raw) return
|
|
const stored: unknown = JSON.parse(raw)
|
|
if (!Array.isArray(stored)) return
|
|
const restored: PlotRow[] = []
|
|
for (const item of stored) {
|
|
if (typeof item !== 'object' || item === null) continue
|
|
const { expr, color, visible } = item as Partial<Record<'expr' | 'color' | 'visible', unknown>>
|
|
restored.push({
|
|
id: nextId++,
|
|
expr: typeof expr === 'string' ? expr : '',
|
|
color: typeof color === 'string' ? color : PALETTE[restored.length % PALETTE.length]!,
|
|
visible: visible !== false,
|
|
})
|
|
}
|
|
if (restored.length) rows.value = restored.slice(0, MAX_ROWS)
|
|
} catch {
|
|
/* storage unreadable — the default functions stay */
|
|
}
|
|
})
|
|
|
|
useSeoMeta({
|
|
title: () => t('calc.metaTitle'),
|
|
description: () => t('calc.metaDescription'),
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="calc-stage">
|
|
<PlotCanvas :series="series" />
|
|
|
|
<Transition name="calc-dock">
|
|
<aside v-if="dockOpen" class="calc-dock" :aria-label="t('calc.functionsPanel')">
|
|
<div class="calc-dock-head">
|
|
<h2 class="calc-dock-title">{{ t('calc.panelHeading') }}</h2>
|
|
<button
|
|
type="button"
|
|
class="panel-icon-btn"
|
|
:title="t('calc.collapse')"
|
|
:aria-label="t('calc.collapse')"
|
|
@click="toggleDock()"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="m15 18-6-6 6-6" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<ul class="calc-rows">
|
|
<li v-for="e in entries" :key="e.row.id" class="calc-item" :class="{ invalid: !!e.error }">
|
|
<div class="calc-row">
|
|
<button
|
|
type="button"
|
|
class="calc-swatch"
|
|
:style="{ background: e.row.color }"
|
|
:title="t('calc.cycleColor')"
|
|
:aria-label="t('calc.cycleColor')"
|
|
@click="cycleColor(e.row.id)"
|
|
/>
|
|
<label class="calc-input-box">
|
|
<span class="calc-y" aria-hidden="true">y=</span>
|
|
<input
|
|
v-model="e.row.expr"
|
|
class="calc-input"
|
|
:class="{ 'input-error': !!e.error }"
|
|
type="text"
|
|
:placeholder="t('calc.placeholder')"
|
|
:aria-label="t('calc.expressionAria')"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
autocapitalize="off"
|
|
@keydown.enter="onEnter(e.row.id)"
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
class="panel-icon-btn"
|
|
:title="e.row.visible ? t('calc.hide') : t('calc.show')"
|
|
:aria-label="e.row.visible ? t('calc.hide') : t('calc.show')"
|
|
:aria-pressed="String(e.row.visible)"
|
|
@click="toggleVisible(e.row.id)"
|
|
>
|
|
<svg v-if="e.row.visible" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 8 10 8a13.2 13.2 0 0 1-1.67 2.68" />
|
|
<path d="M6.61 6.61A13.5 13.5 0 0 0 2 12s3.5 8 10 8a9.7 9.7 0 0 0 5.39-1.61" />
|
|
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
|
<path d="m2 2 20 20" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="panel-icon-btn calc-remove"
|
|
:title="t('calc.remove')"
|
|
:aria-label="t('calc.remove')"
|
|
@click="removeRow(e.row.id)"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<span v-if="e.error" class="calc-error">{{
|
|
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc.errorSyntax')
|
|
}}</span>
|
|
</li>
|
|
</ul>
|
|
|
|
<button v-if="rows.length < MAX_ROWS" type="button" class="btn btn-ghost btn-sm calc-add" @click="addRow">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
{{ t('calc.add') }}
|
|
</button>
|
|
</aside>
|
|
</Transition>
|
|
|
|
<button
|
|
v-if="!dockOpen"
|
|
type="button"
|
|
class="calc-dock-open"
|
|
:title="t('calc.expand')"
|
|
:aria-label="t('calc.expand')"
|
|
@click="toggleDock()"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="m9 18 6-6-6-6" />
|
|
</svg>
|
|
{{ t('calc.panelHeading') }}
|
|
</button>
|
|
</div>
|
|
</template>
|