Calc2
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s

This commit is contained in:
2026-10-02 02:14:29 +02:00
parent 325394150d
commit e36fef7290
21 changed files with 1745 additions and 46 deletions

View File

@@ -0,0 +1,268 @@
<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>