This commit is contained in:
268
frontend/app/pages/calculator.vue
Normal file
268
frontend/app/pages/calculator.vue
Normal 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>
|
||||
Reference in New Issue
Block a user