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,8 +498,7 @@ 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],
@ -519,8 +519,20 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
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)
})
})
})