From 2c458c2efc8e3e1d638fe1cdf846fa632912e270 Mon Sep 17 00:00:00 2001 From: Jeff Levesque Date: Sun, 20 Sep 2026 20:01:10 -0400 Subject: [PATCH 1/6] #80: encoding.js, read the whole vocabulary path out of an ontology uri --- jsx/import/animation/encoding.js | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/jsx/import/animation/encoding.js b/jsx/import/animation/encoding.js index 9bec689..f3beeb9 100644 --- a/jsx/import/animation/encoding.js +++ b/jsx/import/animation/encoding.js @@ -22,9 +22,35 @@ import { colors, colors_categorical, color_other, color_tail } from '../general/colors.js'; import filterSchema, { GRAPH_NODE_TYPES } from './filter-schema.js'; -// ontology uris are '/ontology//'; the id prefix is the +// +// the vocabulary a node type is published under: everything between +// '/ontology/' and the type name, joined on hyphens. The id prefix is the // fallback for anything that does not match. -const NAMESPACE_FROM_URI = /\/ontology\/([^/]+)\//; +// +// The WHOLE path, rather than its first segment, because the builder nests +// vocabularies under their source -- 'ontology/bls/jolts/OpeningsRate' -- and +// takes enrichment vocabularies to 'ontology//enrichment/'. Reading the +// first segment there answers 'bls' for ten different vocabularies: the current +// build puts 118 of its 151 node types under that one source, so three quarters +// of the graph would resolve to a single colour, on both surfaces, while still +// looking like a working encoding. That is the state this module's own header +// records climbing out of, and it would arrive silently. +// +// Note: a FLAT uri -- 'ontology/jolts/OpeningsRate', which is every uri +// published today -- comes out of this byte-identical to what the old +// first-segment rule gave it. Verified across all 151 types in the +// published build: 17 namespaces before, the same 17 after. So this can +// ship ahead of the builder and change nothing until its output moves. +// +// Note: the hyphen join is not a new convention. Published builds already carry +// 'market-quotes', 'sec-filings' and 'sec-common', so 'bls-jolts' reads +// as one of the same family. +// +// Note: enrichment is why the LAST segment is not enough either. Every source +// has one, and taking the final vocabulary alone would pool them into a +// single 'enrichment' namespace belonging to nobody. +// +const NAMESPACE_FROM_URI = /\/ontology\/(.+)\/[^/]+$/; /** * the namespace a node type belongs to. @@ -37,7 +63,7 @@ export function sourceNamespace(meta, id) { const match = NAMESPACE_FROM_URI.exec(uri); if (match) { - return match[1]; + return match[1].replace(/\//g, '-'); } const underscore = id.indexOf('_'); From 693edd540d25161a6b4870a2f74d45eb9b72a325 Mon Sep 17 00:00:00 2001 From: Jeff Levesque Date: Sun, 20 Sep 2026 20:01:10 -0400 Subject: [PATCH 2/6] #80: graph-cluster.jsx, let the canvas edge push the cluster back inside --- jsx/import/animation/graph-cluster.jsx | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/jsx/import/animation/graph-cluster.jsx b/jsx/import/animation/graph-cluster.jsx index cd2c917..218e638 100644 --- a/jsx/import/animation/graph-cluster.jsx +++ b/jsx/import/animation/graph-cluster.jsx @@ -257,6 +257,39 @@ const LINK_DISTANCE_BACKGROUND = 40; // runs off the sides, as the gray field behind it deliberately does. It // is a backdrop, and a backdrop that is cropped still reads as one. // +// +// the canvas edge pushes back. +// +// The cluster had no viewport bound of any kind -- the gray field has one and +// it does not -- while pointerForce adds velocity with no ceiling at all: +// +// n.vx += (dx / dist) * push; +// +// against a centring force of strength 0.04 at an ambient alpha of 0.05. So +// sweeping the cursor along the cluster's rim shoves nodes outward faster than +// the centring recovers them, and the outermost svg clips at its own box, so +// what a reader sees is the graph cut off rather than drawn over anything. +// +// It shows at the TOP first, because that is where the clearance is thinnest, +// and spreading the cluster in #78 made it thinner still: the layout went from +// 607px tall to 660px while the canvas did not grow, which on any 768-high +// window leaves fourteen pixels above the cluster. One sweep erases that. +// +// Proportional to the overshoot rather than a fixed shove, which makes this a +// spring instead of a wall: a node a pixel over is nudged, one fifty px over is +// pulled hard, and a cluster that genuinely wants more room than the canvas has +// settles against the edge rather than stacking along it. That last part is why +// this is not the hard clamp() the gray field uses -- right for a lattice +// snapping back to fixed home spots, wrong for a layout still finding its shape. +// +// Note: deliberately NOT scaled by alpha, which is what makes it work at all. +// The cluster idles at AMBIENT_ALPHA forever; an alpha-scaled correction +// would fade out exactly when the drift and the cursor do not. Neither of +// the other two custom forces scales either. +// +const EDGE_MARGIN = 24; +const EDGE_STRENGTH = 0.25; + const CHARGE_LARGE = -200; const CHARGE_SMALL = -100; const COLLIDE_GAP = 8; @@ -878,6 +911,52 @@ class GraphCluster extends Component { }); }; + // + // keep the cluster inside the canvas -- see EDGE_MARGIN. + // + // Note: the live viewport, not the one this closure captured. A resize + // does not rebuild the simulation, so the captured pair goes + // stale and the boundary would sit where the window used to be. + // 'viewW'/'viewH' exist for exactly this and are kept current by + // applyResize. + // + // Note: every width, including a phone. #78 let the cluster run off the + // sides there, reasoning that 60 node types at this spacing want + // about 480px across a 390px screen and that bounding them would + // crush the layout. Measured, it does not: a phone is tall, the + // settled cluster only fills 577 of its 764 usable pixels, and a + // bound layout redistributes into that slack rather than + // compressing. It comes out 330x577 instead of 482x584 -- five + // node types that were off the canvas come back, the guaranteed + // daylight is unchanged at 16px, and the median gap between + // neighbours goes UP, from 16px to 18px. + // + const edgeForce = () => { + const viewW = this.viewW || width; + const viewH = this.viewH || height; + + // + // Note: no 'is this a background node' guard, unlike pointerForce + // above. The gray field is not in this simulation -- it is a + // separate array the tick handler positions by hand -- so + // nothing here can reach it, and a guard would be a branch + // that never runs. ('background' is a property of LINKS, see + // LINK_DISTANCE_BACKGROUND.) + // + nodes.forEach((n) => { + const edge = EDGE_MARGIN + n.r; + const left = edge - n.x; + const right = n.x - (viewW - edge); + const top = edge - n.y; + const bottom = n.y - (viewH - edge); + + if (left > 0) n.vx += left * EDGE_STRENGTH; + if (right > 0) n.vx -= right * EDGE_STRENGTH; + if (top > 0) n.vy += top * EDGE_STRENGTH; + if (bottom > 0) n.vy -= bottom * EDGE_STRENGTH; + }); + }; + // ambient wander: nudge each node along a slow per-node sine cycle so // the cluster is always gently drifting/jittering, even when idle. let driftT = 0; @@ -909,6 +988,8 @@ class GraphCluster extends Component { .force('y', d3.forceY(height / 2).strength(0.04)) .force('pointer', pointerForce) .force('drift', driftForce) + // last, so it corrects whatever the two above just added + .force('edge', edgeForce) .on('tick', () => { // wobble the gray field gently around its fixed home grid const colored = this.nodes; @@ -1241,6 +1322,8 @@ export default GraphCluster; // with, rather than against copies of them that go stale silently // export { + EDGE_MARGIN, + EDGE_STRENGTH, LINK_DISTANCE_BASE, LINK_DISTANCE_SCALE, CHARGE_LARGE, From 0ae7ce3f3042ed12c560846cb9bc219636a65390 Mon Sep 17 00:00:00 2001 From: Jeff Levesque Date: Sun, 20 Sep 2026 20:01:11 -0400 Subject: [PATCH 3/6] #80: encoding.test.js, pin the nested and enrichment uris to their vocabulary --- jsx/__tests__/animation/encoding.test.js | 59 ++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/jsx/__tests__/animation/encoding.test.js b/jsx/__tests__/animation/encoding.test.js index bae7936..a6757f4 100644 --- a/jsx/__tests__/animation/encoding.test.js +++ b/jsx/__tests__/animation/encoding.test.js @@ -88,6 +88,65 @@ describe('sourceNamespace', () => { )).toBe('bls'); }); + // + // the builder nests vocabularies under their source, and publishes the + // nested spelling. Reading the first segment after '/ontology/' answers + // the SOURCE there, which pools ten vocabularies -- 118 of the current + // build's 151 node types -- into one namespace and one colour. + // + it('reads the vocabulary out of a nested uri, not the source', () => { + expect(sourceNamespace( + { source_type_uri: 'https://example.com/ontology/bls/jolts/OpeningsRate' }, + 'jolts_OpeningsRate' + )).toBe('bls-jolts'); + }); + + it('keeps an enrichment vocabulary with the source it belongs to', () => { + // + // every source has one, so the LAST segment alone would pool them all + // into a single 'enrichment' namespace belonging to nobody. + // + expect(sourceNamespace( + { source_type_uri: 'https://example.com/ontology/bls/enrichment/UnifiedDay' }, + 'bls_enrichment_UnifiedDay' + )).toBe('bls-enrichment'); + + expect(sourceNamespace( + { source_type_uri: 'https://example.com/ontology/sec/enrichment/Filing' }, + 'sec_enrichment_Filing' + )).toBe('sec-enrichment'); + }); + + it('keeps reading a flat uri exactly as it always did', () => { + // + // this is the case that lets the change ship before the builder's does: + // every uri published today is flat, and none of them moves. + // + expect(sourceNamespace( + { source_type_uri: 'https://example.com/ontology/jolts/OpeningsRate' }, + 'jolts_OpeningsRate' + )).toBe('jolts'); + }); + + it('goes as deep as the uri does', () => { + expect(sourceNamespace( + { source_type_uri: 'https://example.com/ontology/a/b/c/Type' }, + 'x_Type' + )).toBe('a-b-c'); + }); + + it('falls back for a uri that names a namespace rather than a type', () => { + // + // a trailing slash leaves no type segment to stop at. The id prefix + // answers the same thing here anyway, which is why this is a fallback + // rather than a special case. + // + expect(sourceNamespace( + { source_type_uri: 'https://example.com/ontology/bls/' }, + 'bls_CensusRegion' + )).toBe('bls'); + }); + it('falls back to the id prefix when there is no uri', () => { expect(sourceNamespace({}, 'market_EquityQuote')).toBe('market'); }); From 7cc737ae1d0894e1643d00429bf5f39b0efdc789 Mon Sep 17 00:00:00 2001 From: Jeff Levesque Date: Sun, 20 Sep 2026 20:01:11 -0400 Subject: [PATCH 4/6] #80: graph-cluster-interaction.test.jsx, pin the boundary at every edge --- .../graph-cluster-interaction.test.jsx | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/jsx/__tests__/animation/graph-cluster-interaction.test.jsx b/jsx/__tests__/animation/graph-cluster-interaction.test.jsx index f5ab11f..b28390e 100644 --- a/jsx/__tests__/animation/graph-cluster-interaction.test.jsx +++ b/jsx/__tests__/animation/graph-cluster-interaction.test.jsx @@ -27,6 +27,7 @@ import GraphCluster, { segClosest, CHARGE_SMALL, GRAPH_TOP_PAD, + EDGE_MARGIN, } from '../../import/animation/graph-cluster.jsx'; import { colors } from '../../import/general/colors.js'; import schema from '../fixtures/graph-schema.mock.json'; @@ -1108,3 +1109,154 @@ describe('the touch handlers', () => { expect(page.touchedAt).toBeGreaterThan(0); }); }); + + +// +// the canvas edge pushes back. +// +// The cluster had no viewport bound at all while pointerForce adds velocity with +// no ceiling, so sweeping the cursor along its rim shoved nodes out of frame -- +// off the top first, where the clearance is thinnest. +// +describe('the canvas edge', () => { + // + // a node placed `over` px past one edge, with the viewport stated: the force + // reads the live size rather than whatever the closure captured, so a test + // sets it the same way a resize would. + // + function strayed(page, axis, over, view = { w: 1200, h: 800 }) { + const node = page.nodes[0]; + + page.viewW = view.w; + page.viewH = view.h; + node.x = view.w / 2; + node.y = view.h / 2; + node.vx = 0; + node.vy = 0; + + const edge = EDGE_MARGIN + node.r; + + if (axis === 'top') node.y = edge - over; + if (axis === 'bottom') node.y = view.h - edge + over; + if (axis === 'left') node.x = edge - over; + if (axis === 'right') node.x = view.w - edge + over; + + page.simulation.force('edge')(1); + + return node; + } + + it('pushes a node back down when it strays over the top', () => { + const { page } = setup(); + + expect(strayed(page, 'top', 40).vy).toBeGreaterThan(0); + }); + + it('pushes back up, left and right from the other three edges', () => { + const { page } = setup(); + + expect(strayed(page, 'bottom', 40).vy).toBeLessThan(0); + expect(strayed(page, 'left', 40).vx).toBeGreaterThan(0); + expect(strayed(page, 'right', 40).vx).toBeLessThan(0); + }); + + // + // the four edges are independent, so a node past two of them at once gets + // both pushes and comes back diagonally. Worth holding explicitly: a + // boundary written as one 'which edge is nearest' branch would pick a side + // and leave the corner leaking, and a phone is where that shows -- the + // cluster is widest against the sides while the drift and the cursor are + // still moving it up and down. + // + it.each([ + ['top left', 'left', 'top', 1, 1], + ['top right', 'right', 'top', -1, 1], + ['bottom left', 'left', 'bottom', 1, -1], + ['bottom right', 'right', 'bottom', -1, -1], + ])('pushes a node out of the %s corner on both axes', (_name, across, down, sx, sy) => { + const { page } = setup(); + const view = { w: 390, h: 760 }; + const node = page.nodes[0]; + const edge = EDGE_MARGIN + node.r; + + page.viewW = view.w; + page.viewH = view.h; + node.x = across === 'left' ? edge - 50 : view.w - edge + 50; + node.y = down === 'top' ? edge - 50 : view.h - edge + 50; + node.vx = 0; + node.vy = 0; + + page.simulation.force('edge')(1); + + expect(Math.sign(node.vx)).toBe(sx); + expect(Math.sign(node.vy)).toBe(sy); + }); + + it('leaves a node inside the margin alone', () => { + // + // a spring at the boundary, not a force field across the canvas + // + const { page } = setup(); + const node = strayed(page, 'top', -20); + + expect(node.vx).toBe(0); + expect(node.vy).toBe(0); + }); + + it('pushes harder the further out the node is', () => { + const { page } = setup(); + + const near = strayed(page, 'top', 10).vy; + const far = strayed(page, 'top', 100).vy; + + expect(far).toBeGreaterThan(near); + }); + + it('follows a resize rather than bounding the window that has gone', () => { + // + // a resize does not rebuild the simulation, so a boundary read from the + // captured size would sit where the window used to be. + // + const { page } = setup(); + + const inside = strayed(page, 'bottom', -60, { w: 1200, h: 1400 }); + expect(inside.vy).toBe(0); + + const outside = strayed(page, 'bottom', 60, { w: 1200, h: 400 }); + expect(outside.vy).toBeLessThan(0); + }); + + it('bounds a phone as well, which is where nodes were being lost', () => { + // + // #78 let the cluster run off the sides here, on the reasoning that it + // wants more width than a phone has and bounding it would crush the + // layout. Measured, it does not: the cluster only fills 577 of a + // phone's 764 usable pixels vertically, so a bound layout spreads into + // that slack instead. Five node types come back on screen and the + // median gap between neighbours goes up rather than down. + // + const { page } = setup(); + const node = strayed(page, 'left', 120, { w: 390, h: 760 }); + + expect(node.vx).toBeGreaterThan(0); + }); + + it('cannot reach the gray field, which is not in the simulation', () => { + // + // the lattice snaps back to fixed home spots and the tick handler + // bounds it already. It is a separate array rather than simulation + // nodes, which is WHY the force needs no guard against it -- this + // holds the reason, since a guard would be a branch that never runs. + // + // Note: written against the simulation's own node list rather than by + // shoving a field node and checking it did not move. That version + // passed whatever the force did, because the object it moved was + // never handed to the force in the first place. + // + const { page } = setup(); + const inSimulation = page.simulation.nodes(); + + expect(page.background.nodes.length).toBeGreaterThan(0); + expect(inSimulation.some((n) => page.background.nodes.includes(n))).toBe(false); + }); +}); From e78f61f1adad5e49785f130700b4f59f7497aa71 Mon Sep 17 00:00:00 2001 From: Jeff Levesque Date: Sun, 20 Sep 2026 20:06:29 -0400 Subject: [PATCH 5/6] #80: knowledge-graph.json, nest the vocabulary in the documented uris --- documentation/api/openapi/knowledge-graph.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/documentation/api/openapi/knowledge-graph.json b/documentation/api/openapi/knowledge-graph.json index 38fa159..2004d2c 100644 --- a/documentation/api/openapi/knowledge-graph.json +++ b/documentation/api/openapi/knowledge-graph.json @@ -309,7 +309,8 @@ "type": "integer" }, "source_type_uri": { - "type": "string" + "type": "string", + "description": "The ontology term this node type comes from. The path after /ontology/ is the vocabulary, which is nested under the source that publishes it -- ontology/bls/cpi/Category, and ontology//enrichment/ for terms derived during the build. Read the whole path rather than its first segment: the first segment names the source, and one source publishes many vocabularies." }, "category": { "type": "string" @@ -391,12 +392,12 @@ "node_types": { "cpi_Category": { "count": 322, - "source_type_uri": "https://jefflevesque.com/ontology/cpi/Category", + "source_type_uri": "https://jefflevesque.com/ontology/bls/cpi/Category", "category": "entity" }, "cpi_OneMonthPercentChange": { "count": 2548, - "source_type_uri": "https://jefflevesque.com/ontology/cpi/OneMonthPercentChange", + "source_type_uri": "https://jefflevesque.com/ontology/bls/cpi/OneMonthPercentChange", "category": "entity" } }, From b3ae96b584381055b5eaf52fb69f7e58b87ac700 Mon Sep 17 00:00:00 2001 From: Jeff Levesque Date: Sun, 20 Sep 2026 20:06:29 -0400 Subject: [PATCH 6/6] #80: api-examples.test.js, hold the documented uri to the namespace rule --- jsx/__tests__/general/api-examples.test.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/jsx/__tests__/general/api-examples.test.js b/jsx/__tests__/general/api-examples.test.js index 6258a7a..35434de 100644 --- a/jsx/__tests__/general/api-examples.test.js +++ b/jsx/__tests__/general/api-examples.test.js @@ -21,6 +21,7 @@ import getData from '../../import/general/get-data.js'; import getBlsDistribution from '../../import/general/get-data/distribution/bls.js'; import { getGraphListing, getGraphById } from '../../import/general/get-graph-schema.js'; import filterSchema from '../../import/animation/filter-schema.js'; +import { sourceNamespace } from '../../import/animation/encoding.js'; import { performanceUrl, datalakeUrl } from '../../import/general/api-url.js'; const OPENAPI = path.join(__dirname, '..', '..', '..', 'documentation', 'api', 'openapi'); @@ -133,4 +134,24 @@ describe('knowledge graph, as the /graph page loads it', () => { expect(Object.keys(drawn.node_types).sort()).toEqual(['cpi_Category', 'cpi_OneMonthPercentChange']); expect(Object.keys(drawn.edge_types)).toHaveLength(1); }); + + it('resolves the documented uris to the vocabulary that colours them', async () => { + // + // the example is what a reader copies, and its uris are what the graph + // reads a namespace out of -- which is the colour channel for both the + // front page and /graph. A documented uri the namespace rule disagrees + // with is a documented api this site would draw wrong. + // + // 'bls-cpi' rather than 'bls': the builder nests a vocabulary under the + // source that publishes it, and one source publishes ten of them. The + // first segment alone would pool them into one colour. + // + answering(schemaMedia.example); + + const schema = await getGraphById('all-sources.2026-09.20260916T171546Z.1024d'); + const drawn = Object.entries(schema.node_types) + .map(([id, meta]) => sourceNamespace(meta, id)); + + expect([...new Set(drawn)]).toEqual(['bls-cpi']); + }); });