Calc2
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s

This commit is contained in:
2026-10-02 02:14:29 +02:00
parent 325394150d
commit e36fef7290
21 changed files with 1745 additions and 46 deletions

View File

@@ -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>