Graph calculator improvmentes
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s

This commit is contained in:
2026-10-02 14:53:00 +02:00
parent e36fef7290
commit 68b74eb60b
21 changed files with 1092 additions and 392 deletions

View File

@@ -1,21 +1,23 @@
<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
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 { PlotFunction } from '~/utils/plot-expression'
import type { PlaneFunction, PlotFunction } from '~/utils/plot-expression'
export interface PlotSeries {
id: number
color: string
visible: boolean
fn: PlotFunction | null
}
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[] }>()
@@ -47,6 +49,7 @@ const colors = {
gridMajor: '#cfd5e3',
axis: '#8a93a8',
tick: '#8a93a8',
pointRing: '#ffffff',
}
function readColors() {
const cs = getComputedStyle(document.documentElement)
@@ -55,6 +58,7 @@ function readColors() {
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
@@ -83,6 +87,12 @@ function formatReadout(v: number): string {
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
@@ -173,13 +183,25 @@ function draw() {
ctx.fillText('0', axisX - 5, axisY + 9)
}
/* the curves last, one pixel column at a time */
/* 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 || !s.fn) continue
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
@@ -205,6 +227,122 @@ function draw() {
}
}
/* 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() {