Files
mathew/frontend/app/components/PlotCanvas.vue
Aran Roig e36fef7290
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s
Calc2
2026-10-02 02:14:29 +02:00

373 lines
12 KiB
Vue
Raw 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 y = f(x) curves, each evaluated once per pixel column.
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 { PlotFunction } from '~/utils/plot-expression'
export interface PlotSeries {
id: number
color: string
visible: boolean
fn: PlotFunction | 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',
}
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)
}
/* 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
}
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, one pixel column at a time */
for (const s of props.series) {
if (!s.visible || !s.fn) continue
ctx.strokeStyle = s.color
ctx.lineWidth = 2
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
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()
}
}
/* 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>