Ai slop
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 41s

This commit is contained in:
2026-10-03 14:13:08 +02:00
parent 68b74eb60b
commit 6df7950161
45 changed files with 4594 additions and 125 deletions

View File

@@ -0,0 +1,832 @@
<script setup lang="ts">
/* The calculator's space: one canvas painted with a labelled box — a floor
grid, the three axes with their numbers — and any number of surfaces,
each z = f(x, y) sampled into a mesh over the plane and laid down quad by
quad, farthest first, every one shaded by how its face lies against the
light. No 3D library: the camera orbits the box's centre and the
projection is the schoolbook one — a point lands where the line to the
eye crosses the picture plane. A quad whose corners are not all finite,
or not all under the drawn height, is simply left out, so 1/(x·y) keeps
its holes rather than a false sheet; the height the box spans is the
data's own, its extremes trimmed so one pole cannot flatten the rest.
Drag turns the scene, the wheel moves closer or away, and a double-click
returns to the default view. Grid, axes and labels follow the site
theme, accent included. */
import type { SurfaceFunction } from '~/utils/plot-expression'
export interface SurfaceSeries {
id: number
color: string
visible: boolean
fn: SurfaceFunction
}
const props = defineProps<{ series: SurfaceSeries[] }>()
const wrapEl = ref<HTMLElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursor = ref<'grab' | 'grabbing'>('grab')
const { t } = useI18n()
/* the space the surfaces stand on, as the reader sets it in the card:
where each edge runs to, and how finely the mesh cuts each side —
clamped short, since a frame holds (n − 1)² quads per surface. z by
default follows the surfaces' own height; the card's switch hands the
two bounds to the reader instead. One person's space, kept in
localStorage between visits, the way the rows and the dock are */
const SPACE_KEY = 'mathew-calc3d-space'
const SUBDIV_MIN = 2
const SUBDIV_MAX = 500
const space = reactive({
xMin: -6,
xMax: 6,
yMin: -6,
yMax: 6,
zAuto: true,
zMin: -1,
zMax: 1,
subdiv: 40,
})
/* the height the box drew last — the auto range the card reads out, and
the prefill the manual bounds take when auto switches off, so the
scene never jumps on the toggle */
const drawnZlo = ref(-1)
const drawnZhi = ref(1)
/* the camera: azimuth and elevation about the scene's centre, and how far
in it stands. Plain mutable state — it moves with every drag and wheel,
and the double-click sets it back. */
const cam = { yaw: 0.96, pitch: 0.42, zoom: 1 }
const YAW0 = 0.96
const PITCH0 = 0.42
const MIN_PITCH = -1.42
const MAX_PITCH = 1.42
const MIN_ZOOM = 0.15
const MAX_ZOOM = 30
/* how strong the perspective is: the eye stands this many scene radii
back from the picture plane */
const EYE = 5.5
let width = 0
let height = 0
let dpr = 1
/* the scene is turned and fitted into a unit ball: its centre and radius
follow the space as set — the edges' middle, the height range drawn —
so whatever it holds fills the picture. All are recomputed at the top
of every paint. */
let cx = 0
let cy = 0
let cz = 0
let radius = 1
/* world → camera: yaw about z, then tilt about the screen's horizontal;
x is screen-right, y screen-up, z depth away from the eye */
function toView(x: number, y: number, z: number) {
const px = (x - cx) / radius
const py = (y - cy) / radius
const pz = (z - cz) / radius
const sa = Math.sin(cam.yaw)
const ca = Math.cos(cam.yaw)
const se = Math.sin(cam.pitch)
const ce = Math.cos(cam.pitch)
return {
x: px * ca + py * sa,
y: -px * sa * se + py * ca * se + pz * ce,
z: -px * sa * ce + py * ca * ce - pz * se,
}
}
/* camera → canvas: the line to the eye, read at the picture plane. At the
scene's middle (z = 0) one scene unit is 42% of the shorter edge. */
function toScreen(v: { x: number; y: number; z: number }) {
const s = ((Math.min(width, height) / 2) * 0.84 * EYE * cam.zoom) / (EYE + v.z)
return { x: width / 2 + v.x * s, y: height / 2 - v.y * s }
}
/* 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 light hangs above and to the left of the eye; faces are shaded by
how squarely they turn to it (both sides of a face read, so the mesh's
underside is no darker than its top) */
const LIGHT = { x: -0.3, y: 0.55, z: -0.78 }
const SHADE_CACHE = new Map<string, string>()
function shade(hex: string, light: number): string {
const level = Math.round(light * 48)
const key = `${hex}|${level}`
const cached = SHADE_CACHE.get(key)
if (cached) return cached
const rgb = /^#[0-9a-f]{6}$/i.test(hex) ? Number.parseInt(hex.slice(1), 16) : 0x8a93a8
const at = (shift: number) =>
Math.max(0, Math.min(255, Math.round((((rgb >> shift) & 255) * level) / 48)))
const css = `rgb(${at(16)},${at(8)},${at(0)})`
SHADE_CACHE.set(key, css)
return css
}
/* about six labelled lines along an axis, at a 1/2/5 × 10ⁿ step */
function niceStep(span: number) {
const rough = span / 6
const pow = Math.pow(10, Math.floor(Math.log10(rough)))
const m = rough / pow
const mult = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10
return mult * pow
}
function formatTick(v: number, step: number): string {
if (Math.abs(v) < step / 2) 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)
}
/* the space as it may be drawn: honest edges (an unfinished or reversed
entry falls back to the default pair) and a clamped mesh fineness.
Read at the top of every paint; the height range fills in once the
surfaces have been sampled */
interface Scene {
xLo: number
xHi: number
yLo: number
yHi: number
zlo: number
zhi: number
n: number // mesh points along one edge; the quads are (n − 1)²
}
function pair(lo: unknown, hi: unknown, dLo: number, dHi: number): [number, number] {
return typeof lo === 'number' && typeof hi === 'number' && Number.isFinite(lo) && Number.isFinite(hi) && lo < hi
? [lo, hi]
: [dLo, dHi]
}
function readScene(): Scene {
const [xLo, xHi] = pair(space.xMin, space.xMax, -6, 6)
const [yLo, yHi] = pair(space.yMin, space.yMax, -6, 6)
const raw = Number(space.subdiv)
const subdiv = Number.isFinite(raw)
? Math.min(SUBDIV_MAX, Math.max(SUBDIV_MIN, Math.round(raw)))
: 40
return { xLo, xHi, yLo, yHi, zlo: -1, zhi: 1, n: subdiv + 1 }
}
/* where the i-th mesh point falls along an edge, and where the k-th
multiple of a step falls between two edges, zeros and all */
const at = (lo: number, hi: number, i: number, n: number) => lo + ((hi - lo) * i) / (n - 1)
function marks(step: number, lo: number, hi: number): number[] {
const out: number[] = []
for (let k = Math.ceil(lo / step - 1e-9); k * step <= hi + 1e-9; k++) out.push(k * step)
return out
}
/* the floor line each horizontal axis runs along: 0 when the ground
holds it, else the edge of the ground that does */
const axisLine = (lo: number, hi: number) => Math.min(Math.max(lo, 0), hi)
/* one surface sampled on the mesh; anything the numbers refuse (NaN, ±∞)
stays a hole the mesh will not paper over */
function sample(sc: Scene, s: SurfaceFunction): Float64Array {
const n = sc.n
const z = new Float64Array(n * n)
for (let j = 0; j < n; j++) {
const y = at(sc.yLo, sc.yHi, j, n)
for (let i = 0; i < n; i++) {
const v = s(at(sc.xLo, sc.xHi, i, n), y)
z[j * n + i] = Number.isFinite(v) ? v : NaN
}
}
return z
}
/* one quantile of a sorted row of heights — the range trims to the 1st and
99th so a lone pole cannot stretch the box flat */
function quantile(sorted: number[], p: number): number {
const at = (sorted.length - 1) * p
const lo = Math.floor(at)
const hi = Math.ceil(at)
return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (at - lo)
}
interface Quad {
pts: { x: number; y: number }[]
depth: number
fill: string
}
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 sc = readScene()
/* one pass over the live surfaces: sample them whole, gather every
finite height, and let those decide the box's height — unless the
reader has taken the height in hand and switched the auto off */
const meshes: { color: string; z: Float64Array }[] = []
const seen: number[] = []
for (const s of props.series) {
if (!s.visible) continue
const z = sample(sc, s.fn)
meshes.push({ color: s.color, z })
for (let k = 0; k < z.length; k++) if (Number.isFinite(z[k]!)) seen.push(z[k]!)
}
const manual =
!space.zAuto && Number.isFinite(space.zMin) && Number.isFinite(space.zMax) && space.zMin < space.zMax
let zlo: number
let zhi: number
if (manual) {
zlo = space.zMin
zhi = space.zMax
} else if (seen.length) {
seen.sort((a, b) => a - b)
const lo = quantile(seen, 0.01)
const hi = quantile(seen, 0.99)
if (hi - lo < 1e-12) {
zlo = lo - 1
zhi = lo + 1
} else {
const pad = (hi - lo) * 0.06
zlo = lo - pad
zhi = hi + pad
}
} else {
zlo = -1
zhi = 1
}
sc.zlo = zlo
sc.zhi = zhi
drawnZlo.value = zlo
drawnZhi.value = zhi
cx = (sc.xLo + sc.xHi) / 2
cy = (sc.yLo + sc.yHi) / 2
cz = (zlo + zhi) / 2
radius = Math.hypot((sc.xHi - sc.xLo) / 2, (sc.yHi - sc.yLo) / 2, (zhi - zlo) / 2)
/* the vertical edge nearest the eye is the one that carries the z axis
and its numbers, so they never hide behind the scene's own centre;
of the four, the closest is the one with the least depth in toView */
const sa = Math.sin(cam.yaw)
const ca = Math.cos(cam.yaw)
let zx = sc.xHi
let zy = sc.yHi
let nearest = Infinity
for (const [x, y] of [
[sc.xHi, sc.yHi],
[sc.xHi, sc.yLo],
[sc.xLo, sc.yHi],
[sc.xLo, sc.yLo],
] as [number, number][]) {
const depth = -x * sa + y * ca // the px/py term of toView's z, before the tilt
if (depth < nearest) {
nearest = depth
zx = x
zy = y
}
}
drawBox(ctx, sc, zx, zy)
drawSurfaces(ctx, meshes, sc)
drawLabels(ctx, sc, zx, zy)
}
/* the box the scene lives in: twelve edges and a floor grid the ground
reads against */
function drawBox(ctx: CanvasRenderingContext2D, sc: Scene, zx: number, zy: number) {
const p = (x: number, y: number, z: number) => toScreen(toView(x, y, z))
const { xLo, xHi, yLo, yHi, zlo, zhi } = sc
ctx.strokeStyle = colors.gridMinor
ctx.lineWidth = 1
ctx.beginPath()
for (const m of marks(niceStep(xHi - xLo), xLo, xHi)) {
const a = p(m, yLo, zlo)
const b = p(m, yHi, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
for (const m of marks(niceStep(yHi - yLo), yLo, yHi)) {
const a = p(xLo, m, zlo)
const b = p(xHi, m, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
ctx.strokeStyle = colors.gridMajor
ctx.beginPath()
const edges: [number, number, number, number, number, number][] = [
[xLo, yLo, zlo, xHi, yLo, zlo], [xHi, yLo, zlo, xHi, yHi, zlo],
[xHi, yHi, zlo, xLo, yHi, zlo], [xLo, yHi, zlo, xLo, yLo, zlo],
[xLo, yLo, zhi, xHi, yLo, zhi], [xHi, yLo, zhi, xHi, yHi, zhi],
[xHi, yHi, zhi, xLo, yHi, zhi], [xLo, yHi, zhi, xLo, yLo, zhi],
[xLo, yLo, zlo, xLo, yLo, zhi], [xHi, yLo, zlo, xHi, yLo, zhi],
[xHi, yHi, zlo, xHi, yHi, zhi], [xLo, yHi, zlo, xLo, yHi, zhi],
]
for (const e of edges) {
const a = p(e[0], e[1], e[2])
const b = p(e[3], e[4], e[5])
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
// the x and y axes along the floor — along 0 while the ground holds it,
// else along the edge that does — and the z axis up the near edge
const y0 = axisLine(yLo, yHi)
const x0 = axisLine(xLo, xHi)
ctx.strokeStyle = colors.axis
ctx.lineWidth = 1.4
ctx.beginPath()
let a = p(xLo, y0, zlo)
let b = p(xHi, y0, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
a = p(x0, yLo, zlo)
b = p(x0, yHi, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
a = p(zx, zy, zlo)
b = p(zx, zy, zhi)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.stroke()
}
/* the surfaces, painted farthest-first: every quad of every mesh keeps its
view-space depth, all are sorted once together, so two surfaces crossing
each other still lay down the nearer one last */
function drawSurfaces(
ctx: CanvasRenderingContext2D,
meshes: { color: string; z: Float64Array }[],
sc: Scene,
) {
const n = sc.n
const { zlo, zhi } = sc
const quads: Quad[] = []
for (const m of meshes) {
for (let j = 0; j < n - 1; j++) {
const y0 = at(sc.yLo, sc.yHi, j, n)
const y1 = at(sc.yLo, sc.yHi, j + 1, n)
for (let i = 0; i < n - 1; i++) {
const x0 = at(sc.xLo, sc.xHi, i, n)
const x1 = at(sc.xLo, sc.xHi, i + 1, n)
const h00 = m.z[j * n + i]!
const h10 = m.z[j * n + i + 1]!
const h01 = m.z[(j + 1) * n + i]!
const h11 = m.z[(j + 1) * n + i + 1]!
if (!Number.isFinite(h00) || !Number.isFinite(h10) || !Number.isFinite(h01) || !Number.isFinite(h11))
continue
// a corner over the drawn height belongs to a pole's neighbourhood:
// the quad is left out, and the mesh keeps the hole honest
if (
h00 < zlo || h00 > zhi || h10 < zlo || h10 > zhi ||
h01 < zlo || h01 > zhi || h11 < zlo || h11 > zhi
)
continue
const v00 = toView(x0, y0, h00)
const v10 = toView(x1, y0, h10)
const v01 = toView(x0, y1, h01)
const v11 = toView(x1, y1, h11)
// the face's normal from its diagonals, in the camera's own frame
const ux = v11.x - v00.x
const uy = v11.y - v00.y
const uz = v11.z - v00.z
const wx = v10.x - v01.x
const wy = v10.y - v01.y
const wz = v10.z - v01.z
const nx = uy * wz - uz * wy
const ny = uz * wx - ux * wz
const nz = ux * wy - uy * wx
const len = Math.hypot(nx, ny, nz)
const light = len > 1e-12 ? 0.44 + 0.74 * Math.abs((nx * LIGHT.x + ny * LIGHT.y + nz * LIGHT.z) / len) : 0.74
quads.push({
pts: [toScreen(v00), toScreen(v10), toScreen(v11), toScreen(v01)],
depth: (v00.z + v10.z + v01.z + v11.z) / 4,
fill: shade(m.color, light),
})
}
}
}
quads.sort((a, b) => b.depth - a.depth) // farthest first, painter's order
ctx.lineJoin = 'round'
ctx.lineWidth = 1
for (const q of quads) {
ctx.beginPath()
ctx.moveTo(q.pts[0]!.x, q.pts[0]!.y)
ctx.lineTo(q.pts[1]!.x, q.pts[1]!.y)
ctx.lineTo(q.pts[2]!.x, q.pts[2]!.y)
ctx.lineTo(q.pts[3]!.x, q.pts[3]!.y)
ctx.closePath()
// filled and stroked in the same tone, so the antialiasing seams between
// neighbouring quads close and the surface reads as one sheet
ctx.fillStyle = q.fill
ctx.strokeStyle = q.fill
ctx.fill()
ctx.stroke()
}
}
/* axes' names and numbers, painted last so they read over any surface */
function drawLabels(ctx: CanvasRenderingContext2D, sc: Scene, zx: number, zy: number) {
const p = (x: number, y: number, z: number) => toScreen(toView(x, y, z))
const { xLo, xHi, yLo, yHi, zlo, zhi } = sc
const y0 = axisLine(yLo, yHi)
const x0 = axisLine(xLo, xHi)
ctx.fillStyle = colors.tick
ctx.font = "11px 'Computer Modern', Georgia, serif"
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const xstep = niceStep(xHi - xLo)
for (const m of marks(xstep, xLo, xHi)) {
if (Math.abs(m) < 1e-9) continue
const a = p(m, y0, zlo)
ctx.fillText(formatTick(m, xstep), a.x, a.y + 5)
}
const ystep = niceStep(yHi - yLo)
for (const m of marks(ystep, yLo, yHi)) {
if (Math.abs(m) < 1e-9) continue
const b = p(x0, m, zlo)
ctx.fillText(formatTick(m, ystep), b.x, b.y + 5)
}
const zstep = niceStep(zhi - zlo)
ctx.textAlign = zx >= cx ? 'left' : 'right'
ctx.textBaseline = 'middle'
const off = zx >= cx ? 8 : -8
for (const m of marks(zstep, (zhi - zlo) / 2)) {
const v = cz + m
if (v < zlo || v > zhi) continue
const a = p(zx, zy, v)
ctx.fillText(formatTick(v, zstep), a.x + off, a.y)
}
ctx.font = "italic 13px 'Computer Modern', Georgia, serif"
ctx.fillStyle = colors.axis
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const xl = p(xHi, y0, zlo)
ctx.fillText('x', xl.x + 10, xl.y + 4)
const yl = p(x0, yHi, zlo)
ctx.fillText('y', yl.x - 10, yl.y + 4)
const zl = p(zx, zy, zhi)
ctx.fillText('z', zl.x + off, zl.y - 8)
}
/* 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 resetView() {
cam.yaw = YAW0
cam.pitch = PITCH0
cam.zoom = 1
requestDraw()
}
function zoomBy(factor: number) {
cam.zoom = Math.min(Math.max(cam.zoom * factor, MIN_ZOOM), MAX_ZOOM)
requestDraw()
}
/* the configuration card that folds out under the toolbar: the space the
surfaces stand in — the ground's two edges, the height last drawn, and
how finely the mesh cuts each side */
const menuOpen = ref(false)
const toolsEl = ref<HTMLElement | null>(null)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
/* the card folds away the way the graph's display menu does: a click
outside it (the scene's own drags answer as clicks too), or esc */
function onDocClick(e: MouseEvent) {
if (menuOpen.value && !toolsEl.value?.contains(e.target as Node)) menuOpen.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && menuOpen.value) menuOpen.value = false
}
/* switching the auto height off prefills the manual bounds with the range
the surfaces were drawn at, so the scene never jumps on the toggle */
function toggleZAuto() {
if (space.zAuto) {
space.zMin = Math.round(drawnZlo.value * 1000) / 1000
space.zMax = Math.round(drawnZhi.value * 1000) / 1000
}
space.zAuto = !space.zAuto
}
/* a bound of the auto height, spelled the way the card reads it: three
decimals plain, a power of ten when the number outgrows that */
function bound(v: number): string {
const s = !Number.isFinite(v)
? '—'
: v === 0
? '0'
: Math.abs(v) >= 1e5 || Math.abs(v) < 1e-3
? v.toExponential(2)
: String(Math.round(v * 1000) / 1000)
return s.replace('-', '\u2212')
}
/* the space is the reader's own too: every edit repaints the scene, and
the setting is kept between visits — the unfinished number typed into
a field persists as it was typed and simply draws as nothing */
function saveSpace() {
try {
localStorage.setItem(SPACE_KEY, JSON.stringify(space))
} catch {
/* private mode etc. — the space just won't persist */
}
}
watch(space, () => {
requestDraw()
saveSpace()
})
function restoreSpace() {
try {
const raw = localStorage.getItem(SPACE_KEY)
if (!raw) return
const stored: unknown = JSON.parse(raw)
if (typeof stored !== 'object' || stored === null) return
const s = stored as Partial<Record<keyof typeof space, unknown>>
for (const key of ['xMin', 'xMax', 'yMin', 'yMax', 'zMin', 'zMax', 'subdiv'] as const) {
if (typeof s[key] === 'number') space[key] = s[key] as number
}
if (typeof s.zAuto === 'boolean') space.zAuto = s.zAuto
} catch {
/* storage unreadable — the default space stands */
}
}
let turning = 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) {
canvasEl.value?.setPointerCapture(e.pointerId)
turning = true
lastPt = localPoint(e)
cursor.value = 'grabbing'
}
function onPointerMove(e: PointerEvent) {
if (!turning) return
const p = localPoint(e)
/* the scene turns with the drag, the way a globe does under the hand */
cam.yaw -= (p.x - lastPt.x) * 0.0062
cam.pitch = Math.min(MAX_PITCH, Math.max(MIN_PITCH, cam.pitch + (p.y - lastPt.y) * 0.0062))
lastPt = p
requestDraw()
}
function onPointerUp() {
turning = false
cursor.value = 'grab'
}
function onWheel(e: WheelEvent) {
e.preventDefault()
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
cam.zoom = Math.min(Math.max(cam.zoom * Math.exp(-dy * step), MIN_ZOOM), MAX_ZOOM)
requestDraw()
}
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
onMounted(() => {
restoreSpace()
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'],
})
document.addEventListener('click', onDocClick)
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
themeObserver?.disconnect()
document.removeEventListener('click', onDocClick)
window.removeEventListener('keydown', onKeydown)
})
/* a new surface, an edited one, a colour or visibility change — the scene
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"
@wheel="onWheel"
@dblclick="resetView"
/>
<div ref="toolsEl" class="plot-tools">
<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>
<button
type="button"
class="panel-icon-btn"
:class="{ open: menuOpen }"
:title="t('calc3d.config')"
:aria-label="t('calc3d.config')"
:aria-expanded="String(menuOpen)"
@click="toggleMenu"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3" />
<path d="M14 2v4M8 10v4M16 18v4" />
</svg>
</button>
</div>
<div v-if="menuOpen" class="plot-config" role="group" :aria-label="t('calc3d.config')">
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">x</span>
<span class="plot-config-bounds">
<input
v-model.number="space.xMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'x' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.xMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'x' })"
/>
</span>
</div>
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">y</span>
<span class="plot-config-bounds">
<input
v-model.number="space.yMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'y' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.yMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'y' })"
/>
</span>
</div>
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">z</span>
<span v-if="space.zAuto" class="plot-config-value">{{ bound(drawnZlo) }} … {{ bound(drawnZhi) }}</span>
<span v-else class="plot-config-bounds">
<input
v-model.number="space.zMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'z' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.zMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'z' })"
/>
</span>
</div>
<button
type="button"
class="plot-config-toggle"
role="switch"
:aria-checked="String(space.zAuto)"
@click="toggleZAuto"
>
<span>{{ t('calc3d.configZNote') }}</span>
<span class="plot-config-switch" :class="{ on: space.zAuto }" aria-hidden="true">
<span class="knob" />
</span>
</button>
<div class="plot-config-row">
<span class="plot-config-label">{{ t('calc3d.configSubdivisions') }}</span>
<input
v-model.number="space.subdiv"
class="plot-config-input"
type="number"
min="2"
max="500"
step="1"
autocomplete="off"
:aria-label="t('calc3d.configSubdivisions')"
/>
</div>
</div>
</div>
</div>
</template>