Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions documentation/api/openapi/knowledge-graph.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/<source>/enrichment/<Term> 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"
Expand Down Expand Up @@ -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"
}
},
Expand Down
59 changes: 59 additions & 0 deletions jsx/__tests__/animation/encoding.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
152 changes: 152 additions & 0 deletions jsx/__tests__/animation/graph-cluster-interaction.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
21 changes: 21 additions & 0 deletions jsx/__tests__/general/api-examples.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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']);
});
});
32 changes: 29 additions & 3 deletions jsx/import/animation/encoding.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<origin>/ontology/<namespace>/<Type>'; 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/<source>/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.
Expand All @@ -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('_');
Expand Down
Loading
Loading