diff --git a/quartz.layout.ts b/quartz.layout.ts index 970a5be34..6c7161586 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -41,7 +41,11 @@ export const defaultContentPageLayout: PageLayout = { Component.Explorer(), ], right: [ - Component.Graph(), + Component.Graph({ + localGraph: { + defaultZoom: 2, + }, + }), Component.DesktopOnly(Component.TableOfContents()), Component.Backlinks(), ], diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx index 907372e93..cb76a7a9e 100644 --- a/quartz/components/Graph.tsx +++ b/quartz/components/Graph.tsx @@ -19,6 +19,7 @@ export interface D3Config { showTags: boolean focusOnHover?: boolean enableRadial?: boolean + defaultZoom?: number } interface GraphOptions { @@ -41,6 +42,7 @@ const defaultOptions: GraphOptions = { removeTags: [], focusOnHover: false, enableRadial: false, + defaultZoom: 1, }, globalGraph: { drag: true, @@ -56,6 +58,7 @@ const defaultOptions: GraphOptions = { removeTags: [], focusOnHover: true, enableRadial: true, + defaultZoom: 1, }, } diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index a669b0547..00dd447b9 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -87,6 +87,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) { showTags, focusOnHover, enableRadial, + defaultZoom, } = JSON.parse(graph.dataset["cfg"]!) as D3Config const data: Map = new Map( @@ -497,30 +498,41 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) { } if (enableZoom) { - select(app.canvas).call( - zoom() - .extent([ - [0, 0], - [width, height], - ]) - .scaleExtent([0.25, 4]) - .on("zoom", ({ transform }) => { - currentTransform = transform - stage.scale.set(transform.k, transform.k) - stage.position.set(transform.x, transform.y) + const zoomBehavior = zoom() + .extent([ + [0, 0], + [width, height], + ]) + .scaleExtent([0.25, 4]) + .on("zoom", ({ transform }) => { + currentTransform = transform + stage.scale.set(transform.k, transform.k) + stage.position.set(transform.x, transform.y) - // zoom adjusts opacity of labels too - const scale = transform.k * opacityScale - let scaleOpacity = Math.max((scale - 1) / 3.75, 0) - const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label) + // zoom adjusts opacity of labels too + const scale = transform.k * opacityScale + let scaleOpacity = Math.max((scale - 1) / 3.75, 0) + const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label) - for (const label of labelsContainer.children) { - if (!activeNodes.includes(label)) { - label.alpha = scaleOpacity - } + for (const label of labelsContainer.children) { + if (!activeNodes.includes(label)) { + label.alpha = scaleOpacity } - }), - ) + } + }) + + const canvas = select(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 diff --git a/quartz/components/scripts/graph.test.ts b/quartz/components/scripts/graph.test.ts new file mode 100644 index 000000000..7e687b376 --- /dev/null +++ b/quartz/components/scripts/graph.test.ts @@ -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) + }) + }) +})