This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user