Graph calculator improvmentes
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user