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,372 @@
<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>