Files
mathew/frontend/app/components/Scene3D.vue
Aran Roig 6df7950161
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 41s
Ai slop
2026-10-03 14:13:08 +02:00

833 lines
28 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 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>