This commit is contained in:
@@ -120,6 +120,13 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.graph') }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/calculator" class="nav-link" :title="t('sidebar.calculatorHint')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 3v17a1 1 0 0 0 1 1h17" />
|
||||
<path d="M4.5 16.5c2.6 0 3.4-10.5 6.8-10.5s3.9 7 8.2 7" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.calculator') }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
|
||||
@@ -870,7 +870,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<div class="preview-label">{{ t('editor.livePreview') }}</div>
|
||||
<MarkdownView :content="previewContent || `*${t('editor.noPreview')}*`" />
|
||||
<MarkdownView :content="previewContent || `*${t('editor.noPreview')}*`" :article-lang="isVersioned ? activeLang : language" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@ function topicLabel(tag: string) {
|
||||
</header>
|
||||
|
||||
<div class="article-body">
|
||||
<MarkdownView :content="article.content ?? ''" :namespace="slug" intercept-links />
|
||||
<MarkdownView :content="article.content ?? ''" :namespace="slug" :article-lang="language" intercept-links />
|
||||
</div>
|
||||
|
||||
<footer v-if="article.tags?.length" class="article-topics">
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
one article's text into another is an edge between them. A small
|
||||
hand-rolled force simulation (pairwise repulsion, springs along the
|
||||
edges, a gentle pull to the centre) settles the nodes into a shape,
|
||||
and everything is painted on one canvas: drag the background to pan,
|
||||
the wheel to zoom, a node to pull it around, and let go of a node
|
||||
where it was pressed to open that article. */
|
||||
and everything is painted on one canvas — each node wears the colour of
|
||||
its first topic, an article with no topics the theme accent. Drag the
|
||||
background to pan, the wheel to zoom, a node to pull it around, and let
|
||||
go of a node where it was pressed to open that article. */
|
||||
|
||||
export interface GraphNodeDto {
|
||||
slug: string
|
||||
@@ -28,12 +29,44 @@ const cursor = ref<'grab' | 'grabbing' | 'pointer'>('grab')
|
||||
const hoverTitle = ref('')
|
||||
const { t } = useI18n()
|
||||
|
||||
/* the display menu that folds out under the toolbar — one switch for now:
|
||||
whether nodes wear their topic's colour or all wear the accent. The
|
||||
choice is the reader's own, kept between visits. */
|
||||
const COLOR_KEY = 'mathew-graph-color'
|
||||
const menuOpen = ref(false)
|
||||
const colorNodes = ref(true)
|
||||
const toolsEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value
|
||||
}
|
||||
|
||||
function toggleColorNodes() {
|
||||
colorNodes.value = !colorNodes.value
|
||||
try {
|
||||
localStorage.setItem(COLOR_KEY, colorNodes.value ? '1' : '0')
|
||||
} catch {
|
||||
/* private mode etc. — the choice just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
/* the menu folds away the way the app's other menus do: a click outside
|
||||
it (the canvas'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
|
||||
}
|
||||
|
||||
/* the simulation is plain mutable state — it ticks every frame, and
|
||||
Vue would only slow it down */
|
||||
interface SimNode {
|
||||
slug: string
|
||||
title: string
|
||||
deg: number
|
||||
topic: string | null
|
||||
color: string
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
@@ -87,7 +120,6 @@ let downPt = { x: 0, y: 0 }
|
||||
const colors = {
|
||||
edge: '#888',
|
||||
accent: '#4f5fe8',
|
||||
accent2: '#0ea5c9',
|
||||
halo: '#ffffff',
|
||||
label: '#5b6478',
|
||||
text: '#1b2130',
|
||||
@@ -98,10 +130,36 @@ function readColors() {
|
||||
cs.getPropertyValue(name).trim() || fallback
|
||||
colors.edge = at('--border-strong', colors.edge)
|
||||
colors.accent = at('--accent', colors.accent)
|
||||
colors.accent2 = at('--accent-2', colors.accent2)
|
||||
colors.halo = at('--bg', colors.halo)
|
||||
colors.label = at('--muted', colors.label)
|
||||
colors.text = at('--text', colors.text)
|
||||
/* untopiced nodes wear the accent — recolour them when it moves */
|
||||
for (const n of simNodes) if (!n.topic) n.color = colors.accent
|
||||
}
|
||||
|
||||
/* a node wears the colour of the article's first topic: one categorical
|
||||
palette, each topic pinned to a slot by a hash of its name so the same
|
||||
topic keeps its colour across rebuilds. An article with no topics stays
|
||||
on the theme accent. */
|
||||
const PALETTE = [
|
||||
'#e5484d', '#f76808', '#ffb224', '#46a758', '#12a594',
|
||||
'#0091ff', '#8e4ec6', '#e93d82', '#946f4a', '#3e63dd',
|
||||
]
|
||||
const paletteSlot = new Map<string, string>()
|
||||
function topicColor(topic: string): string {
|
||||
let c = paletteSlot.get(topic)
|
||||
if (!c) {
|
||||
let h = 0
|
||||
for (let i = 0; i < topic.length; i++) h = (h * 31 + topic.charCodeAt(i)) >>> 0
|
||||
c = PALETTE[h % PALETTE.length]!
|
||||
paletteSlot.set(topic, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
/* the first non-blank tag names the node's topic; one topic, one colour */
|
||||
function firstTopic(tags?: string[]): string | null {
|
||||
const tag = tags?.find((t) => t.trim() !== '')
|
||||
return tag ? tag.trim().toLowerCase() : null
|
||||
}
|
||||
|
||||
const toWorldX = (px: number) => (px - view.x) / view.k
|
||||
@@ -115,10 +173,13 @@ function build() {
|
||||
and the repulsion unfolds it into a disc from there */
|
||||
const angle = i * 2.399963229728653
|
||||
const radius = 26 * Math.sqrt(i + 1)
|
||||
const topic = firstTopic(n.tags)
|
||||
const node: SimNode = {
|
||||
slug: n.slug,
|
||||
title: n.title,
|
||||
deg: Math.max(n.links ?? 0, 0),
|
||||
topic,
|
||||
color: topic ? topicColor(topic) : colors.accent,
|
||||
x: radius * Math.cos(angle),
|
||||
y: radius * Math.sin(angle),
|
||||
vx: 0,
|
||||
@@ -254,8 +315,11 @@ function draw() {
|
||||
const dimmed = !!focus && !lit?.has(n.slug)
|
||||
const isFocus = n === focus
|
||||
|
||||
/* the topic's colour — or the plain accent when the reader has the
|
||||
colours switched off */
|
||||
const fill = colorNodes.value ? n.color : colors.accent
|
||||
ctx.globalAlpha = dimmed ? 0.16 : 1
|
||||
ctx.fillStyle = isFocus ? colors.accent2 : colors.accent
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
@@ -265,7 +329,7 @@ function draw() {
|
||||
ctx.stroke()
|
||||
if (isFocus) {
|
||||
ctx.globalAlpha = 0.35
|
||||
ctx.strokeStyle = colors.accent
|
||||
ctx.strokeStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, r + 4.5, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
@@ -482,6 +546,11 @@ let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
colorNodes.value = localStorage.getItem(COLOR_KEY) !== '0'
|
||||
} catch {
|
||||
/* storage unreadable — keep the nodes coloured */
|
||||
}
|
||||
readColors()
|
||||
const wrap = wrapEl.value!
|
||||
const canvas = canvasEl.value!
|
||||
@@ -503,6 +572,8 @@ onMounted(() => {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme', 'data-accent'],
|
||||
})
|
||||
document.addEventListener('click', onDocClick)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
build()
|
||||
/* a few silent ticks so the first painted frame is already a shape */
|
||||
for (let i = 0; i < 40; i++) tick()
|
||||
@@ -514,6 +585,8 @@ onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(raf)
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
document.removeEventListener('click', onDocClick)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
/* a fresh fetch (a new article, a new link) rebuilds the map */
|
||||
@@ -544,31 +617,65 @@ watch(
|
||||
@dblclick="onDoubleClick"
|
||||
@pointerleave="clearHover"
|
||||
/>
|
||||
<div class="graph-toolbar">
|
||||
<button type="button" class="panel-icon-btn" :title="t('graph.fit')" :aria-label="t('graph.fit')" @click="fitView">
|
||||
<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('graph.zoomIn')" :aria-label="t('graph.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('graph.zoomOut')" :aria-label="t('graph.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" :title="t('graph.relayout')" :aria-label="t('graph.relayout')" @click="reheat">
|
||||
<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="M21 12a9 9 0 1 1-2.6-6.4" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div ref="toolsEl" class="graph-tools">
|
||||
<div class="graph-toolbar">
|
||||
<button type="button" class="panel-icon-btn" :title="t('graph.fit')" :aria-label="t('graph.fit')" @click="fitView">
|
||||
<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('graph.zoomIn')" :aria-label="t('graph.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('graph.zoomOut')" :aria-label="t('graph.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" :title="t('graph.relayout')" :aria-label="t('graph.relayout')" @click="reheat">
|
||||
<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="M21 12a9 9 0 1 1-2.6-6.4" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:class="{ open: menuOpen }"
|
||||
:title="t('graph.display')"
|
||||
:aria-label="t('graph.display')"
|
||||
: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" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.6 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.6-1.7H16c3.1 0 5.6-2.5 5.6-5.5C21.9 5.8 17.4 2 12 2Z" />
|
||||
<circle cx="7.5" cy="8" r=".5" />
|
||||
<circle cx="12" cy="5.5" r=".5" />
|
||||
<circle cx="16.5" cy="8.5" r=".5" />
|
||||
<circle cx="7" cy="12.5" r=".5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="menuOpen" class="graph-menu" role="group" :aria-label="t('graph.display')">
|
||||
<button
|
||||
type="button"
|
||||
class="graph-menu-row"
|
||||
role="switch"
|
||||
:aria-checked="String(colorNodes)"
|
||||
@click="toggleColorNodes"
|
||||
>
|
||||
<span>{{ t('graph.colorNodes') }}</span>
|
||||
<span class="graph-switch" :class="{ on: colorNodes }" aria-hidden="true">
|
||||
<span class="knob" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,15 +13,70 @@ const props = defineProps<{
|
||||
* keep two articles' "Proof" headings from sharing one id.
|
||||
*/
|
||||
namespace?: string
|
||||
/**
|
||||
* The article's own language: callout boxes (```theorem, …) label
|
||||
* themselves in it, whatever language the interface happens to speak.
|
||||
*/
|
||||
articleLang?: string
|
||||
}>()
|
||||
|
||||
const rendered = computed(() => renderMarkdown(props.content, props.namespace))
|
||||
const rendered = computed(() => renderMarkdown(props.content, props.namespace, props.articleLang))
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/* Callout boxes fold away under their label. The browser's <details> carries
|
||||
the state, but a state flip alone has no motion: the toggle click is caught
|
||||
here and the fold — height and opacity of the box's body — runs through the
|
||||
Web Animations API. Opening sets `open` right away (the body needs to be
|
||||
laid out before it can be measured); closing flips it only once the body
|
||||
has folded away. The box's intended state rides along as data-open, so
|
||||
re-clicking mid-fold picks the animation up where it stands instead of
|
||||
fighting it. */
|
||||
const folds = new WeakMap<HTMLDetailsElement, Animation>()
|
||||
|
||||
function foldBox(details: HTMLDetailsElement, event: MouseEvent) {
|
||||
const body = details.querySelector<HTMLElement>('.md-box-body')
|
||||
const isOpen = details.dataset.open ?? String(details.open)
|
||||
const wantOpen = isOpen !== 'true'
|
||||
details.dataset.open = String(wantOpen)
|
||||
|
||||
// nothing to shrink, or the reader asks for no motion: let <details> answer
|
||||
if (!body || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
event.preventDefault()
|
||||
const running = folds.get(details)
|
||||
// a fold in flight shows its height — the new animation starts there
|
||||
const from = running ? getComputedStyle(body).height : wantOpen ? '0px' : `${body.scrollHeight}px`
|
||||
running?.cancel()
|
||||
if (wantOpen) details.open = true
|
||||
|
||||
body.style.overflow = 'hidden'
|
||||
const anim = body.animate(
|
||||
[
|
||||
{ height: from, opacity: wantOpen ? 0 : 1 },
|
||||
{ height: wantOpen ? `${body.scrollHeight}px` : '0px', opacity: wantOpen ? 1 : 0 },
|
||||
],
|
||||
{ duration: 260, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' },
|
||||
)
|
||||
folds.set(details, anim)
|
||||
anim.onfinish = () => {
|
||||
details.open = wantOpen
|
||||
body.style.overflow = ''
|
||||
folds.delete(details)
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (!props.interceptLinks) return
|
||||
const target = e.target as HTMLElement
|
||||
const summary = target.closest?.('summary')
|
||||
if (summary) {
|
||||
const details = summary.closest('details')
|
||||
if (details) {
|
||||
foldBox(details, e)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!props.interceptLinks) return
|
||||
const anchor = target.closest?.('a')
|
||||
if (!anchor) return
|
||||
const href = anchor.getAttribute('href') || ''
|
||||
|
||||
372
frontend/app/components/PlotCanvas.vue
Normal file
372
frontend/app/components/PlotCanvas.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<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
|
||||
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'
|
||||
|
||||
export interface PlotSeries {
|
||||
id: number
|
||||
color: string
|
||||
visible: boolean
|
||||
fn: PlotFunction | null
|
||||
}
|
||||
|
||||
const props = defineProps<{ series: PlotSeries[] }>()
|
||||
|
||||
const wrapEl = ref<HTMLElement | null>(null)
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const cursor = ref<'grab' | 'grabbing'>('grab')
|
||||
const readout = ref('')
|
||||
const { t } = useI18n()
|
||||
|
||||
/* the view: the world point at the canvas's centre, and how many pixels one
|
||||
unit spans. Plain mutable state — it moves with every pan and zoom. */
|
||||
const view = { cx: 0, cy: 0, scale: 64 }
|
||||
const DEFAULT_SCALE = 64
|
||||
const MIN_SCALE = 1e-7
|
||||
const MAX_SCALE = 1e9
|
||||
|
||||
let width = 0
|
||||
let height = 0
|
||||
let dpr = 1
|
||||
|
||||
const toScreenX = (x: number) => (x - view.cx) * view.scale + width / 2
|
||||
const toScreenY = (y: number) => height / 2 - (y - view.cy) * view.scale
|
||||
const toWorldX = (px: number) => (px - width / 2) / view.scale + view.cx
|
||||
const toWorldY = (py: number) => (height / 2 - py) / view.scale + view.cy
|
||||
|
||||
/* 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 spacing between labelled lines: a 1/2/5 × 10ⁿ step wide enough for
|
||||
about 80 pixels between them, so labels stay readable at every zoom */
|
||||
function niceStep() {
|
||||
const target = 80 / view.scale
|
||||
const pow = Math.pow(10, Math.floor(Math.log10(target)))
|
||||
const m = target / pow
|
||||
const mult = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10
|
||||
return mult * pow
|
||||
}
|
||||
|
||||
function formatTick(v: number, step: number): string {
|
||||
if (v === 0) 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)
|
||||
}
|
||||
|
||||
function formatReadout(v: number): string {
|
||||
const decimals = Math.min(10, Math.max(2, Math.ceil(-Math.log10(view.scale)) + 3))
|
||||
const s = v.toFixed(decimals)
|
||||
return s === '-0.0' || /^-0(\.0+)?$/.test(s) ? s.slice(1) : s
|
||||
}
|
||||
|
||||
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 step = niceStep()
|
||||
const minor = step / 5
|
||||
const x0 = toWorldX(0)
|
||||
const x1 = toWorldX(width)
|
||||
const yBottom = toWorldY(height)
|
||||
const yTop = toWorldY(0)
|
||||
|
||||
/* the faint minors, only while they still have room between them */
|
||||
if (minor * view.scale >= 7) {
|
||||
ctx.strokeStyle = colors.gridMinor
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
for (let k = Math.ceil(x0 / minor); k * minor <= x1; k++) {
|
||||
if (k % 5 === 0) continue // the majors are painted stronger below
|
||||
const px = Math.round(toScreenX(k * minor)) + 0.5
|
||||
ctx.moveTo(px, 0)
|
||||
ctx.lineTo(px, height)
|
||||
}
|
||||
for (let k = Math.ceil(yBottom / minor); k * minor <= yTop; k++) {
|
||||
if (k % 5 === 0) continue
|
||||
const py = Math.round(toScreenY(k * minor)) + 0.5
|
||||
ctx.moveTo(0, py)
|
||||
ctx.lineTo(width, py)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
/* the majors */
|
||||
ctx.strokeStyle = colors.gridMajor
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
for (let k = Math.ceil(x0 / step); k * step <= x1; k++) {
|
||||
if (k === 0) continue // the axis is its own line
|
||||
const px = Math.round(toScreenX(k * step)) + 0.5
|
||||
ctx.moveTo(px, 0)
|
||||
ctx.lineTo(px, height)
|
||||
}
|
||||
for (let k = Math.ceil(yBottom / step); k * step <= yTop; k++) {
|
||||
if (k === 0) continue
|
||||
const py = Math.round(toScreenY(k * step)) + 0.5
|
||||
ctx.moveTo(0, py)
|
||||
ctx.lineTo(width, py)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
/* the axes, once axes */
|
||||
const axisX = toScreenX(0)
|
||||
const axisY = toScreenY(0)
|
||||
ctx.strokeStyle = colors.axis
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
if (axisY >= 0 && axisY <= height) {
|
||||
ctx.moveTo(0, Math.round(axisY) + 0.5)
|
||||
ctx.lineTo(width, Math.round(axisY) + 0.5)
|
||||
}
|
||||
if (axisX >= 0 && axisX <= width) {
|
||||
ctx.moveTo(Math.round(axisX) + 0.5, 0)
|
||||
ctx.lineTo(Math.round(axisX) + 0.5, height)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
/* the numbers: sitting on their axis, sliding to the canvas edge once the
|
||||
axis has been panned away, so the plane always reads */
|
||||
ctx.fillStyle = colors.tick
|
||||
ctx.font = "11px 'Computer Modern', Georgia, serif"
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'top'
|
||||
const labelY = Math.min(Math.max(axisY + 6, 6), height - 18)
|
||||
for (let k = Math.ceil(x0 / step); k * step <= x1; k++) {
|
||||
if (k === 0) continue
|
||||
ctx.fillText(formatTick(k * step, step), toScreenX(k * step), labelY)
|
||||
}
|
||||
ctx.textAlign = 'right'
|
||||
ctx.textBaseline = 'middle'
|
||||
const labelX = Math.min(Math.max(axisX - 8, 38), width - 6)
|
||||
for (let k = Math.ceil(yBottom / step); k * step <= yTop; k++) {
|
||||
if (k === 0) continue
|
||||
ctx.fillText(formatTick(k * step, step), labelX, toScreenY(k * step))
|
||||
}
|
||||
if (axisX >= 0 && axisX <= width && axisY >= 0 && axisY <= height) {
|
||||
ctx.fillText('0', axisX - 5, axisY + 9)
|
||||
}
|
||||
|
||||
/* the curves last, one pixel column at a time */
|
||||
for (const s of props.series) {
|
||||
if (!s.visible || !s.fn) continue
|
||||
ctx.strokeStyle = s.color
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
ctx.beginPath()
|
||||
let pen = false
|
||||
let prevPy = 0
|
||||
for (let px = 0; px <= width; px++) {
|
||||
const y = s.fn(toWorldX(px))
|
||||
if (!Number.isFinite(y)) {
|
||||
pen = false
|
||||
continue
|
||||
}
|
||||
let py = toScreenY(y)
|
||||
// keep far-off coordinates sane for the canvas
|
||||
if (py < -1e4) py = -1e4
|
||||
else if (py > height + 1e4) py = height + 1e4
|
||||
// a jump of more than four canvases between adjacent pixels is an
|
||||
// asymptote, not a line — lift the pen instead of lying about it
|
||||
if (pen && Math.abs(py - prevPy) > height * 4) pen = false
|
||||
if (pen) ctx.lineTo(px, py)
|
||||
else ctx.moveTo(px, py)
|
||||
pen = true
|
||||
prevPy = py
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 clampScale(k: number) {
|
||||
return Math.min(Math.max(k, MIN_SCALE), MAX_SCALE)
|
||||
}
|
||||
|
||||
function zoomAt(px: number, py: number, factor: number) {
|
||||
const k = clampScale(view.scale * factor)
|
||||
/* keep the world point under (px, py) anchored — the centre is recomputed
|
||||
against the new scale, so scale goes in last */
|
||||
const wx = toWorldX(px)
|
||||
const wy = toWorldY(py)
|
||||
view.scale = k
|
||||
view.cx = wx - (px - width / 2) / k
|
||||
view.cy = wy + (py - height / 2) / k
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
function zoomBy(factor: number) {
|
||||
zoomAt(width / 2, height / 2, factor)
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
view.cx = 0
|
||||
view.cy = 0
|
||||
view.scale = DEFAULT_SCALE
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
let panning = 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) {
|
||||
const p = localPoint(e)
|
||||
canvasEl.value?.setPointerCapture(e.pointerId)
|
||||
panning = true
|
||||
lastPt = p
|
||||
cursor.value = 'grabbing'
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
const p = localPoint(e)
|
||||
if (panning) {
|
||||
view.cx -= (p.x - lastPt.x) / view.scale
|
||||
view.cy += (p.y - lastPt.y) / view.scale
|
||||
lastPt = p
|
||||
requestDraw()
|
||||
} else {
|
||||
readout.value = t('calc.cursor', { x: formatReadout(toWorldX(p.x)), y: formatReadout(toWorldY(p.y)) })
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
panning = false
|
||||
cursor.value = 'grab'
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
if (panning) return
|
||||
readout.value = ''
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const p = localPoint(e)
|
||||
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
|
||||
zoomAt(p.x, p.y, Math.exp(-dy * step))
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
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'],
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
})
|
||||
|
||||
/* a new function, an edited one, a colour or visibility change — the plane
|
||||
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"
|
||||
@pointerleave="clearHover"
|
||||
@wheel="onWheel"
|
||||
@dblclick="resetView"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="readout" class="plot-readout" aria-hidden="true">{{ readout }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -202,6 +202,7 @@ const stale = computed(() => {
|
||||
<MarkdownView
|
||||
:content="proposal.base.content || '*…*'"
|
||||
:namespace="`proposal-${proposal.id}-base`"
|
||||
:article-lang="proposal.proposed.language"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -218,6 +219,7 @@ const stale = computed(() => {
|
||||
<MarkdownView
|
||||
:content="proposal.proposed.content || '*…*'"
|
||||
:namespace="`proposal-${proposal.id}-proposed`"
|
||||
:article-lang="proposal.proposed.language"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user