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