Files
mathew/frontend/app/components/PlotCanvas.vue
Aran Roig 68b74eb60b
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
Graph calculator improvmentes
2026-10-02 14:53:00 +02:00

511 lines
17 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/* The graphing calculator's plane: one canvas painted with a labelled grid
and any number of curves — each y = f(x) traced once per pixel column,
each relation F(x, y) = 0 found by marching squares over a coarse grid
(every cell whose corners straddle zero gets a segment, so "y^2 + x^2 = 1"
draws its circle) — and each point "(x, y)" as a labelled dot. Drag to move
the plane, the wheel to zoom — anchored at
the cursor, the way the link map does it — the buttons to zoom from the
middle, and a double-click to return to the default view. A curve breaks
where the numbers break (NaN, ±∞) or where it jumps the canvas between two
pixels: tan(x) keeps its gaps rather than a false connecting line.
Grid, axes and labels follow the site theme, accent included. */
import type { PlaneFunction, PlotFunction } from '~/utils/plot-expression'
export type PlotSeries =
| { id: number; color: string; visible: boolean; kind: 'function'; fn: PlotFunction }
| { id: number; color: string; visible: boolean; kind: 'relation'; fn: PlaneFunction }
// a single dot at fixed coordinates, optionally with a label ("P")
| { id: number; color: string; visible: boolean; kind: 'point'; x: number; y: number; label: string | null }
const props = defineProps<{ series: PlotSeries[] }>()
const wrapEl = ref<HTMLElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursor = ref<'grab' | 'grabbing'>('grab')
const readout = ref('')
const { t } = useI18n()
/* the view: the world point at the canvas's centre, and how many pixels one
unit spans. Plain mutable state — it moves with every pan and zoom. */
const view = { cx: 0, cy: 0, scale: 64 }
const DEFAULT_SCALE = 64
const MIN_SCALE = 1e-7
const MAX_SCALE = 1e9
let width = 0
let height = 0
let dpr = 1
const toScreenX = (x: number) => (x - view.cx) * view.scale + width / 2
const toScreenY = (y: number) => height / 2 - (y - view.cy) * view.scale
const toWorldX = (px: number) => (px - width / 2) / view.scale + view.cx
const toWorldY = (py: number) => (height / 2 - py) / view.scale + view.cy
/* canvas inks follow the site theme, including accent switches */
const colors = {
gridMinor: '#e2e6ef',
gridMajor: '#cfd5e3',
axis: '#8a93a8',
tick: '#8a93a8',
pointRing: '#ffffff',
}
function readColors() {
const cs = getComputedStyle(document.documentElement)
const at = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback
colors.gridMinor = at('--border', colors.gridMinor)
colors.gridMajor = at('--border-strong', colors.gridMajor)
colors.axis = at('--faint', colors.axis)
colors.tick = at('--faint', colors.tick)
colors.pointRing = at('--surface', colors.pointRing)
}
/* the spacing between labelled lines: a 1/2/5 × 10ⁿ step wide enough for
about 80 pixels between them, so labels stay readable at every zoom */
function niceStep() {
const target = 80 / view.scale
const pow = Math.pow(10, Math.floor(Math.log10(target)))
const m = target / pow
const mult = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10
return mult * pow
}
function formatTick(v: number, step: number): string {
if (v === 0) return '0'
if (step >= 1e6 || step < 1e-4) {
const [mant, exp] = v.toExponential(2).split('e')
return `${mant!.replace(/\.?0+$/, '')}e${exp}`
}
const decimals = Math.min(12, Math.max(0, -Math.floor(Math.log10(step))))
return v.toFixed(decimals)
}
function formatReadout(v: number): string {
const decimals = Math.min(10, Math.max(2, Math.ceil(-Math.log10(view.scale)) + 3))
const s = v.toFixed(decimals)
return s === '-0.0' || /^-0(\.0+)?$/.test(s) ? s.slice(1) : s
}
/* a point's coordinate as read on the plane: whole numbers stand plain, the
rest keep six significant figures with the trailing zeros trimmed */
function formatCoord(v: number): string {
return Number.isInteger(v) ? String(v) : String(Number(v.toPrecision(6)))
}
function draw() {
const ctx = canvasEl.value?.getContext('2d')
if (!ctx || width === 0 || height === 0) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, height)
const step = niceStep()
const minor = step / 5
const x0 = toWorldX(0)
const x1 = toWorldX(width)
const yBottom = toWorldY(height)
const yTop = toWorldY(0)
/* the faint minors, only while they still have room between them */
if (minor * view.scale >= 7) {
ctx.strokeStyle = colors.gridMinor
ctx.lineWidth = 1
ctx.beginPath()
for (let k = Math.ceil(x0 / minor); k * minor <= x1; k++) {
if (k % 5 === 0) continue // the majors are painted stronger below
const px = Math.round(toScreenX(k * minor)) + 0.5
ctx.moveTo(px, 0)
ctx.lineTo(px, height)
}
for (let k = Math.ceil(yBottom / minor); k * minor <= yTop; k++) {
if (k % 5 === 0) continue
const py = Math.round(toScreenY(k * minor)) + 0.5
ctx.moveTo(0, py)
ctx.lineTo(width, py)
}
ctx.stroke()
}
/* the majors */
ctx.strokeStyle = colors.gridMajor
ctx.lineWidth = 1
ctx.beginPath()
for (let k = Math.ceil(x0 / step); k * step <= x1; k++) {
if (k === 0) continue // the axis is its own line
const px = Math.round(toScreenX(k * step)) + 0.5
ctx.moveTo(px, 0)
ctx.lineTo(px, height)
}
for (let k = Math.ceil(yBottom / step); k * step <= yTop; k++) {
if (k === 0) continue
const py = Math.round(toScreenY(k * step)) + 0.5
ctx.moveTo(0, py)
ctx.lineTo(width, py)
}
ctx.stroke()
/* the axes, once axes */
const axisX = toScreenX(0)
const axisY = toScreenY(0)
ctx.strokeStyle = colors.axis
ctx.lineWidth = 1.5
ctx.beginPath()
if (axisY >= 0 && axisY <= height) {
ctx.moveTo(0, Math.round(axisY) + 0.5)
ctx.lineTo(width, Math.round(axisY) + 0.5)
}
if (axisX >= 0 && axisX <= width) {
ctx.moveTo(Math.round(axisX) + 0.5, 0)
ctx.lineTo(Math.round(axisX) + 0.5, height)
}
ctx.stroke()
/* the numbers: sitting on their axis, sliding to the canvas edge once the
axis has been panned away, so the plane always reads */
ctx.fillStyle = colors.tick
ctx.font = "11px 'Computer Modern', Georgia, serif"
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const labelY = Math.min(Math.max(axisY + 6, 6), height - 18)
for (let k = Math.ceil(x0 / step); k * step <= x1; k++) {
if (k === 0) continue
ctx.fillText(formatTick(k * step, step), toScreenX(k * step), labelY)
}
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
const labelX = Math.min(Math.max(axisX - 8, 38), width - 6)
for (let k = Math.ceil(yBottom / step); k * step <= yTop; k++) {
if (k === 0) continue
ctx.fillText(formatTick(k * step, step), labelX, toScreenY(k * step))
}
if (axisX >= 0 && axisX <= width && axisY >= 0 && axisY <= height) {
ctx.fillText('0', axisX - 5, axisY + 9)
}
/* the curves last. A function of x is traced one pixel column at a time;
a relation F(x, y) = 0 has no such column form, so it is found by
marching squares over a coarse grid — every cell whose corners straddle
zero earns a short segment, and together they draw the level curve; a
point is a single labelled dot. */
for (const s of props.series) {
if (!s.visible) continue
ctx.strokeStyle = s.color
ctx.lineWidth = 2
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
if (s.kind === 'relation') {
drawRelation(ctx, s.fn)
continue
}
if (s.kind === 'point') {
drawPoint(ctx, s.x, s.y, s.color, s.label)
continue
}
ctx.beginPath()
let pen = false
let prevPy = 0
for (let px = 0; px <= width; px++) {
const y = s.fn(toWorldX(px))
if (!Number.isFinite(y)) {
pen = false
continue
}
let py = toScreenY(y)
// keep far-off coordinates sane for the canvas
if (py < -1e4) py = -1e4
else if (py > height + 1e4) py = height + 1e4
// a jump of more than four canvases between adjacent pixels is an
// asymptote, not a line — lift the pen instead of lying about it
if (pen && Math.abs(py - prevPy) > height * 4) pen = false
if (pen) ctx.lineTo(px, py)
else ctx.moveTo(px, py)
pen = true
prevPy = py
}
ctx.stroke()
}
}
/* one point: a filled dot in the series colour, ringed in the surface tint so
it stays legible over gridlines on paper and slate alike, and labelled with
its name (when it has one) and its coordinates, in the same colour so it
reads as belonging to that dot */
function drawPoint(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
color: string,
label: string | null,
) {
const px = toScreenX(x)
const py = toScreenY(y)
ctx.beginPath()
ctx.arc(px, py, 4, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
ctx.lineWidth = 1.5
ctx.strokeStyle = colors.pointRing
ctx.stroke()
const coords = `(${formatCoord(x)}, ${formatCoord(y)})`
ctx.font = "12px 'Computer Modern', Georgia, serif"
ctx.textAlign = 'left'
ctx.textBaseline = 'bottom'
ctx.fillStyle = color
ctx.fillText(label ? `${label} = ${coords}` : coords, px + 8, py - 6)
}
/* one edge of a marching-squares cell: where the segment between two corners
crosses zero, as a point in screen space — null when the two agree in
sign (no crossing) or either is a bad number */
function edgeCross(ax: number, ay: number, av: number, bx: number, by: number, bv: number) {
if (!Number.isFinite(av) || !Number.isFinite(bv)) return null
if (av >= 0 === bv >= 0) return null
const t = av / (av - bv)
return { x: ax + (bx - ax) * t, y: ay + (by - ay) * t }
}
/* the pixel grid a relation is sampled on, and the largest |F| taken for a
real value — past it a corner reads as a pole, not a crossing, so its cell
is left blank rather than connected by a false line */
const RELATION_CELL = 8
const RELATION_MAG = 1e12
function drawRelation(ctx: CanvasRenderingContext2D, f: PlaneFunction) {
const cols = Math.ceil(width / RELATION_CELL)
const rows = Math.ceil(height / RELATION_CELL)
const nx = cols + 1
const sample = (px: number, py: number): number => {
const v = f(toWorldX(px), toWorldY(py))
return !Number.isFinite(v) || Math.abs(v) > RELATION_MAG ? NaN : v
}
// two rows of samples, kept and swapped as the sweep walks down the canvas
let prev = new Float64Array(nx)
let curr = new Float64Array(nx)
for (let i = 0; i < nx; i++) prev[i] = sample(i * RELATION_CELL, 0)
ctx.beginPath()
let drew = false
for (let j = 0; j < rows; j++) {
const y0 = j * RELATION_CELL
const y1 = Math.min(y0 + RELATION_CELL, height)
for (let i = 0; i < nx; i++) curr[i] = sample(i * RELATION_CELL, y1)
for (let i = 0; i < cols; i++) {
const x0 = i * RELATION_CELL
const x1 = Math.min(x0 + RELATION_CELL, width)
const tl = prev[i]!
const tr = prev[i + 1]!
const bl = curr[i]!
const br = curr[i + 1]!
// a cell with any bad corner is a singularity's neighbourhood: skip it
if (!Number.isFinite(tl) || !Number.isFinite(tr) || !Number.isFinite(bl) || !Number.isFinite(br))
continue
const top = edgeCross(x0, y0, tl, x1, y0, tr)
const right = edgeCross(x1, y0, tr, x1, y1, br)
const bottom = edgeCross(x1, y1, br, x0, y1, bl)
const left = edgeCross(x0, y0, tl, x0, y1, bl)
const idx = ((tl >= 0 ? 8 : 0) | (tr >= 0 ? 4 : 0) | (br >= 0 ? 2 : 0) | (bl >= 0 ? 1 : 0)) >>> 0
if (idx === 0 || idx === 15) continue
const seg = (p: { x: number; y: number } | null, q: { x: number; y: number } | null) => {
if (!p || !q) return
ctx.moveTo(p.x, p.y)
ctx.lineTo(q.x, q.y)
drew = true
}
if (idx === 5 || idx === 10) {
// saddle: four crossings, and the cell centre says how they pair up
const mid = sample((x0 + x1) / 2, (y0 + y1) / 2)
const centreUp = Number.isFinite(mid) ? mid >= 0 : tl >= 0
if (idx === 5) {
seg(top, centreUp ? left : right)
seg(bottom, centreUp ? right : left)
} else {
seg(top, centreUp ? right : left)
seg(bottom, centreUp ? left : right)
}
} else if (idx === 3 || idx === 12) {
seg(left, right)
} else if (idx === 6 || idx === 9) {
seg(top, bottom)
} else {
// one corner cut off: exactly two edges cross, and they form the segment
const pts = [top, right, bottom, left].filter(
(p): p is { x: number; y: number } => p !== null,
)
if (pts.length >= 2) seg(pts[0]!, pts[1]!)
}
}
const swap = prev
prev = curr
curr = swap
}
if (drew) ctx.stroke()
}
/* paint on demand: one frame is scheduled whenever something moved */
let scheduled = false
function requestDraw() {
if (scheduled || !import.meta.client) return
scheduled = true
requestAnimationFrame(() => {
scheduled = false
draw()
})
}
function clampScale(k: number) {
return Math.min(Math.max(k, MIN_SCALE), MAX_SCALE)
}
function zoomAt(px: number, py: number, factor: number) {
const k = clampScale(view.scale * factor)
/* keep the world point under (px, py) anchored — the centre is recomputed
against the new scale, so scale goes in last */
const wx = toWorldX(px)
const wy = toWorldY(py)
view.scale = k
view.cx = wx - (px - width / 2) / k
view.cy = wy + (py - height / 2) / k
requestDraw()
}
function zoomBy(factor: number) {
zoomAt(width / 2, height / 2, factor)
}
function resetView() {
view.cx = 0
view.cy = 0
view.scale = DEFAULT_SCALE
requestDraw()
}
let panning = false
let lastPt = { x: 0, y: 0 }
function localPoint(e: PointerEvent | WheelEvent) {
const rect = canvasEl.value!.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
function onPointerDown(e: PointerEvent) {
const p = localPoint(e)
canvasEl.value?.setPointerCapture(e.pointerId)
panning = true
lastPt = p
cursor.value = 'grabbing'
}
function onPointerMove(e: PointerEvent) {
const p = localPoint(e)
if (panning) {
view.cx -= (p.x - lastPt.x) / view.scale
view.cy += (p.y - lastPt.y) / view.scale
lastPt = p
requestDraw()
} else {
readout.value = t('calc.cursor', { x: formatReadout(toWorldX(p.x)), y: formatReadout(toWorldY(p.y)) })
}
}
function onPointerUp() {
panning = false
cursor.value = 'grab'
}
function clearHover() {
if (panning) return
readout.value = ''
}
function onWheel(e: WheelEvent) {
e.preventDefault()
const p = localPoint(e)
let dy = e.deltaY
if (e.deltaMode === 1) dy *= 16
else if (e.deltaMode === 2) dy *= 100
/* pinch-to-zoom arrives as ctrl+wheel — calm the large deltas down */
const step = e.ctrlKey ? 0.004 : 0.0016
zoomAt(p.x, p.y, Math.exp(-dy * step))
}
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
onMounted(() => {
readColors()
const wrap = wrapEl.value!
const canvas = canvasEl.value!
const resize = () => {
const rect = wrap.getBoundingClientRect()
width = Math.max(rect.width, 1)
height = Math.max(rect.height, 1)
dpr = window.devicePixelRatio || 1
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
requestDraw()
}
resize()
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(wrap)
themeObserver = new MutationObserver(() => {
readColors()
requestDraw()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-accent'],
})
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
themeObserver?.disconnect()
})
/* a new function, an edited one, a colour or visibility change — the plane
is repainted */
watch(() => props.series, requestDraw)
</script>
<template>
<div ref="wrapEl" class="plot-wrap">
<canvas
ref="canvasEl"
class="plot-canvas"
:style="{ cursor }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@pointerleave="clearHover"
@wheel="onWheel"
@dblclick="resetView"
/>
<div class="plot-toolbar">
<button type="button" class="panel-icon-btn" :title="t('calc.resetView')" :aria-label="t('calc.resetView')" @click="resetView">
<svg 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="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M16 3h3a2 2 0 0 1 2 2v3" />
<path d="M8 21H5a2 2 0 0 1-2-2v-3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
</button>
<button type="button" class="panel-icon-btn" :title="t('calc.zoomIn')" :aria-label="t('calc.zoomIn')" @click="zoomBy(1.35)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button type="button" class="panel-icon-btn" :title="t('calc.zoomOut')" :aria-label="t('calc.zoomOut')" @click="zoomBy(1 / 1.35)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
</div>
<div v-if="readout" class="plot-readout" aria-hidden="true">{{ readout }}</div>
</div>
</template>