feat(graph): add defaultZoom option for initial zoom level

Adds a new `defaultZoom` configuration option to the Graph component
that allows setting the initial zoom level for both local and global
graphs.

- Add `defaultZoom` to D3Config interface
- Apply zoom transform on graph initialization when defaultZoom !== 1
- Default value is 1 (no zoom applied, falls back to 1 if undefined)
- Includes test coverage for the zoom transform calculations

Example usage:
```ts
Component.Graph({
  localGraph: {
    defaultZoom: 1.5,
  },
})
```
This commit is contained in:
wh1le 2026-01-11 15:36:59 +00:00
parent f346a01296
commit 8c8a7ac903
4 changed files with 132 additions and 22 deletions

View File

@ -41,7 +41,11 @@ export const defaultContentPageLayout: PageLayout = {
Component.Explorer(), Component.Explorer(),
], ],
right: [ right: [
Component.Graph(), Component.Graph({
localGraph: {
defaultZoom: 2,
},
}),
Component.DesktopOnly(Component.TableOfContents()), Component.DesktopOnly(Component.TableOfContents()),
Component.Backlinks(), Component.Backlinks(),
], ],

View File

@ -19,6 +19,7 @@ export interface D3Config {
showTags: boolean showTags: boolean
focusOnHover?: boolean focusOnHover?: boolean
enableRadial?: boolean enableRadial?: boolean
defaultZoom?: number
} }
interface GraphOptions { interface GraphOptions {
@ -41,6 +42,7 @@ const defaultOptions: GraphOptions = {
removeTags: [], removeTags: [],
focusOnHover: false, focusOnHover: false,
enableRadial: false, enableRadial: false,
defaultZoom: 1,
}, },
globalGraph: { globalGraph: {
drag: true, drag: true,
@ -56,6 +58,7 @@ const defaultOptions: GraphOptions = {
removeTags: [], removeTags: [],
focusOnHover: true, focusOnHover: true,
enableRadial: true, enableRadial: true,
defaultZoom: 1,
}, },
} }

View File

@ -87,6 +87,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
showTags, showTags,
focusOnHover, focusOnHover,
enableRadial, enableRadial,
defaultZoom,
} = JSON.parse(graph.dataset["cfg"]!) as D3Config } = JSON.parse(graph.dataset["cfg"]!) as D3Config
const data: Map<SimpleSlug, ContentDetails> = new Map( const data: Map<SimpleSlug, ContentDetails> = new Map(
@ -497,30 +498,41 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
} }
if (enableZoom) { if (enableZoom) {
select<HTMLCanvasElement, NodeData>(app.canvas).call( const zoomBehavior = zoom<HTMLCanvasElement, NodeData>()
zoom<HTMLCanvasElement, NodeData>() .extent([
.extent([ [0, 0],
[0, 0], [width, height],
[width, height], ])
]) .scaleExtent([0.25, 4])
.scaleExtent([0.25, 4]) .on("zoom", ({ transform }) => {
.on("zoom", ({ transform }) => { currentTransform = transform
currentTransform = transform stage.scale.set(transform.k, transform.k)
stage.scale.set(transform.k, transform.k) stage.position.set(transform.x, transform.y)
stage.position.set(transform.x, transform.y)
// zoom adjusts opacity of labels too // zoom adjusts opacity of labels too
const scale = transform.k * opacityScale const scale = transform.k * opacityScale
let scaleOpacity = Math.max((scale - 1) / 3.75, 0) let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label) const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label)
for (const label of labelsContainer.children) { for (const label of labelsContainer.children) {
if (!activeNodes.includes(label)) { if (!activeNodes.includes(label)) {
label.alpha = scaleOpacity label.alpha = scaleOpacity
}
} }
}), }
) })
const canvas = select<HTMLCanvasElement, NodeData>(app.canvas)
canvas.call(zoomBehavior)
// Apply default zoom (defaults to 1 if not provided)
const zoomLevel = defaultZoom ?? 1
if (zoomLevel !== 1) {
const defaultTransform = zoomIdentity
.translate(width / 2, height / 2)
.scale(zoomLevel)
.translate(-width / 2, -height / 2)
canvas.call(zoomBehavior.transform, defaultTransform)
}
} }
let stopAnimation = false let stopAnimation = false

View File

@ -0,0 +1,91 @@
import test, { describe } from "node:test"
import assert from "node:assert"
import { zoomIdentity } from "d3"
describe("graph", () => {
describe("defaultZoom", () => {
test("should create identity transform when not specified", () => {
const transform = zoomIdentity
assert.strictEqual(transform.k, 1)
assert.strictEqual(transform.x, 0)
assert.strictEqual(transform.y, 0)
})
test("should scale correctly when defaultZoom is 2", () => {
const width = 400
const height = 300
const defaultZoom = 2
const transform = zoomIdentity
.translate(width / 2, height / 2)
.scale(defaultZoom)
.translate(-width / 2, -height / 2)
assert.strictEqual(transform.k, defaultZoom)
assert.strictEqual(transform.x, -200)
assert.strictEqual(transform.y, -150)
})
test("should produce identity-like transform when defaultZoom is 1", () => {
const width = 400
const height = 300
const defaultZoom = 1
const transform = zoomIdentity
.translate(width / 2, height / 2)
.scale(defaultZoom)
.translate(-width / 2, -height / 2)
assert.strictEqual(transform.k, 1)
assert.strictEqual(transform.x, 0)
assert.strictEqual(transform.y, 0)
})
test("should zoom out when defaultZoom is 0.5", () => {
const width = 400
const height = 300
const defaultZoom = 0.5
const transform = zoomIdentity
.translate(width / 2, height / 2)
.scale(defaultZoom)
.translate(-width / 2, -height / 2)
assert.strictEqual(transform.k, 0.5)
assert.strictEqual(transform.x, 100)
assert.strictEqual(transform.y, 75)
})
test("should keep center point stationary after zoom", () => {
const width = 400
const height = 300
const defaultZoom = 2
const transform = zoomIdentity
.translate(width / 2, height / 2)
.scale(defaultZoom)
.translate(-width / 2, -height / 2)
const centerX = width / 2
const centerY = height / 2
const [newX, newY] = transform.apply([centerX, centerY])
assert.strictEqual(newX, centerX)
assert.strictEqual(newY, centerY)
})
test("should default to 1 when defaultZoom is undefined", () => {
const defaultZoom: number | undefined = undefined
const zoomLevel = defaultZoom ?? 1
assert.strictEqual(zoomLevel, 1)
})
test("should use provided value when defaultZoom is defined", () => {
const defaultZoom: number | undefined = 2.5
const zoomLevel = defaultZoom ?? 1
assert.strictEqual(zoomLevel, 2.5)
})
})
})