Skip to content
Open
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
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ npm run lint # ESLint
npm run preview # Preview production build
```

## Deployment

Deployed on **Railway** from the `main` branch. Build config lives in the Railway
dashboard, not in this repo — there is no `railway.json` or `nixpacks.toml` here.

## How queries work

An **Analysis Question** has three parts:
Expand All @@ -51,12 +56,17 @@ When you click Apply, the query engine:
2. **Executes** steps sequentially (`engine/executor.ts`), threading S2 cell sets between steps
3. **Renders** results as map layers (`resultTransformer.ts` → `MapFeature[]`)

Supported entity types: **samples**, **facilities**, **water bodies**
Supported entity types: **samples**, **facilities**, **water bodies**, **wells**, **streams**
Supported relationships: **near** (~1–2 km), **downstream**, **upstream**

Downstream/upstream traces accept an optional cumulative flowpath cutoff
(`Within N km of flow`). Unset, the trace is the full transitive closure.
`node scripts/flow-distance-check.mjs` verifies the bounded trace against the
live endpoints.

## SPARQL endpoints

All hosted at `frink.apps.renci.org`:
All hosted at `apps.okn.us`:

| Endpoint | Used for |
| ------------- | ----------------------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ console.log(results);

Copy a query from the console, run it at:
```
https://frink.apps.renci.org/fiokg/sparql
https://apps.okn.us/fiokg/sparql
```

### 4. Check S2 Cell Counts
Expand Down
13 changes: 13 additions & 0 deletions docs/changelog/2026-W35.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Changelog — Week 35, 2026 (Aug 24 – Aug 30)

---

1. **Move SPARQL Endpoints from FRINK to `apps.okn.us`** (2026-08-28)
FRINK retired `https://frink.apps.renci.org/<kg>/sparql`; all five knowledge graphs began returning 503 and Explorer was unusable for several days. Repointed every endpoint at `https://apps.okn.us/<kg>/sparql`. Reported by David Kedrowski, who hit the same break in his notebooks. `sparqlClient.ts` reads all URLs from the `ENDPOINTS` map and nothing else hardcodes a host, so this was a five-line change. Verified by replaying the real pipeline against the new host: 7 of 8 prebuilt queries pass and all discovery queries return live data instead of falling back to hardcoded constants.

*Files:* `constants/endpoints.ts`, `docs/ARCHITECTURE.md`, `README.md`

2. **Remove Stale `render.yaml`** (2026-08-28)
Deleted the Render blueprint left over from the publish workflow. The app deploys on Railway now, and the stale file was actively misleading about where production runs. Documented the actual deploy target in the README instead.

*Files:* `render.yaml` (deleted), `README.md`
39 changes: 0 additions & 39 deletions render.yaml

This file was deleted.

114 changes: 114 additions & 0 deletions scripts/flow-distance-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Checks the distance-bounded downstream trace against the live SPARQL
// endpoints, using the same planner the app runs.
//
// Reference values come from UC1_CQ2c (New Hampshire, NAICS 488119 airports,
// 30 km): the notebook returns 162 flowlines, but only because it requires
// schema1:address on facilities — a predicate 13 of 144 NH airport facilities
// have. Without that accidental filter its answer is 1,547 flowlines, and ours
// is a superset of exactly that set (we also expand to neighbouring S2 cells
// and add the "+1" segment past the cutoff).
//
// Run: node scripts/flow-distance-check.mjs
import assert from 'node:assert/strict';
import { build } from 'esbuild';

const bundle = await build({
entryPoints: ['src/engine/planner.ts'],
bundle: true,
format: 'esm',
platform: 'neutral',
write: false,
});
const { planPipeline } = await import(
'data:text/javascript;base64,' + Buffer.from(bundle.outputFiles[0].text).toString('base64')
);

const ENDPOINTS = {
sawgraph: 'https://frink.apps.renci.org/sawgraph/sparql',
spatialkg: 'https://frink.apps.renci.org/spatialkg/sparql',
hydrologykg: 'https://frink.apps.renci.org/hydrologykg/sparql',
federation: 'https://frink.apps.renci.org/federation/sparql',
};

async function run(endpoint, query) {
const res = await fetch(ENDPOINTS[endpoint], {
method: 'POST',
headers: {
Accept: 'application/sparql-results+json',
'Content-Type': 'application/sparql-query',
},
body: query,
});
if (!res.ok) throw new Error(`${endpoint} ${res.status}: ${await res.text()}`);
const json = await res.json();
return json.results.bindings.map((row) =>
Object.fromEntries(Object.entries(row).map(([k, v]) => [k, v.value])),
);
}

// "What streams are within 30 km downstream of 488119 (Airports) facilities
// in New Hampshire?" — the notebook's question, expressed in the app's model.
const question = {
blockA: { type: 'streams' },
relationship: { type: 'downstream', maxDistanceKm: 30 },
blockC: {
type: 'facilities',
region: { stateCode: '33' },
facilityFilters: { industryCodes: ['488119'] },
},
};

const steps = planPipeline(question);
const stepTypes = steps.map((s) => s.type);

// The answer set *is* the flowlines, so the supporting stream layer is skipped.
assert.ok(
!stepTypes.includes('GET_FLOWLINE_GEOMETRIES'),
`expected no supporting flowline step, got ${stepTypes.join(', ')}`,
);

const ctx = { question, targetIris: [], anchorIris: [], results: {} };
for (const step of steps) {
const rows = await run(step.endpoint, step.buildQuery(ctx));
ctx.results[step.type] = rows;
if (step.type === 'FIND_TARGET_IRIS') ctx.targetIris = [...new Set(rows.map((r) => r.iri))];
if (step.type === 'FIND_ANCHOR_IRIS') ctx.anchorIris = [...new Set(rows.map((r) => r.iri))];
console.log(`${step.type}: ${rows.length} rows`);
}

const streams = ctx.targetIris.length;
const facilities = ctx.anchorIris.length;
console.log(`\n${streams} streams within 30 km downstream of ${facilities} NH airport facilities`);

// The "+1" fringe must land past the cutoff, so some flowlines carry a
// distance above the threshold — that is the point of it.
const overThreshold = (ctx.results['GET_FLOWLINE_GEOMETRIES'] ?? [])
.concat(ctx.results['HYDRATE_TARGET_BY_IRI'] ?? [])
.filter((r) => r.path_length !== undefined && Number(r.path_length) >= 30);
console.log(`${overThreshold.length} flowlines past the 30 km cutoff (the "+1" fringe)`);

// The bound has to actually bind: unbounded, this trace runs to the coast.
const unbounded = planPipeline({ ...question, relationship: { type: 'downstream' } });
const unboundedRows = await run(unbounded[0].endpoint, unbounded[0].buildQuery(ctx));
const unboundedStreams = new Set(unboundedRows.map((r) => r.iri)).size;
console.log(`${unboundedStreams} streams with no distance bound`);

assert.ok(streams > 1547, `expected a superset of the notebook's 1547, got ${streams}`);
// The bound must actually exclude flowlines the unbounded trace reaches.
// Observed 2026-08: 2784 bounded (2757 within the cutoff + 27 fringe) vs 3490
// unbounded — downstreamFlowPathTC in this KG does not reach as far as the
// coast, so the gap is smaller than the notebook's parameters suggest.
assert.ok(
unboundedStreams > streams,
`bound had no effect: ${streams} bounded vs ${unboundedStreams} unbounded`,
);

// Every hydrated stream carries geometry the map can draw.
const hydrated = ctx.results['HYDRATE_TARGET_BY_IRI'] ?? [];
assert.ok(hydrated.length > 0, 'no hydrated streams');
assert.ok(
hydrated.every((r) => r.flowlineWKT?.startsWith('LINESTRING')),
'hydrated streams missing LINESTRING geometry',
);

console.log('\nOK');
6 changes: 6 additions & 0 deletions src/components/Map/MapPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,12 @@ export function MapPopupContent({ feature }: MapPopupProps) {
<td>{props.flowType}</td>
</tr>
)}
{props.pathLength && (
<tr>
<td className="popup-label">Flow distance</td>
<td>{Number(props.pathLength).toFixed(1)} km</td>
</tr>
)}
{feature.id && (
<tr>
<td className="popup-label">Flowline</td>
Expand Down
8 changes: 8 additions & 0 deletions src/components/QueryEditor/EntityBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SampleFilters } from './SampleFilters';
import { FacilityFilters } from './FacilityFilters';
import { WaterBodyFilters } from './WaterBodyFilters';
import { WellFilters } from './WellFilters';
import { StreamFilters } from './StreamFilters';
import { RegionSelector } from './RegionSelector';
import { useState } from 'react';

Expand All @@ -21,6 +22,7 @@ export function EntityBlock({ label, value, onChange }: EntityBlockProps) {
value.sampleFilters?.materialTypes?.length ||
value.facilityFilters?.industryCodes?.length ||
value.waterBodyFilters?.ftypes?.length ||
value.streamFilters?.ftypes?.length ||
value.wellFilters?.wellTypes?.length
)
);
Expand Down Expand Up @@ -62,6 +64,12 @@ export function EntityBlock({ label, value, onChange }: EntityBlockProps) {
onChange={(f) => onChange({ ...value, waterBodyFilters: f })}
/>
)}
{value.type === 'streams' && (
<StreamFilters
value={value.streamFilters}
onChange={(f) => onChange({ ...value, streamFilters: f })}
/>
)}
{value.type === 'wells' && (
<WellFilters
value={value.wellFilters}
Expand Down
1 change: 1 addition & 0 deletions src/components/QueryEditor/EntityTypeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const ENTITY_TYPES: { value: EntityType; label: string }[] = [
{ value: 'samples', label: 'Samples' },
{ value: 'facilities', label: 'Facilities' },
{ value: 'waterBodies', label: 'Surface Water Bodies' },
{ value: 'streams', label: 'Streams / Flowlines' },
{ value: 'wells', label: 'Wells' },
];

Expand Down
24 changes: 24 additions & 0 deletions src/components/QueryEditor/RelationshipSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ const NEAR_DISTANCE_OPTIONS = [
{ value: '4', label: '~4 miles' },
];

// Cumulative flowpath length cutoff. '' = unbounded (today's behavior).
const FLOW_DISTANCE_OPTIONS = [
{ value: '', label: 'Any distance' },
{ value: '5', label: 'Within 5 km of flow' },
{ value: '10', label: 'Within 10 km of flow' },
{ value: '30', label: 'Within 30 km of flow' },
{ value: '50', label: 'Within 50 km of flow' },
{ value: '100', label: 'Within 100 km of flow' },
];

export function RelationshipSelector({ value, onChange }: RelationshipSelectorProps) {
const selected = RELATIONSHIP_TYPES.find((r) => r.value === value.type) || RELATIONSHIP_TYPES[0];
const currentHops = value.hops ?? 1;
Expand All @@ -40,6 +50,20 @@ export function RelationshipSelector({ value, onChange }: RelationshipSelectorPr
isClearable={false}
placeholder="Select relationship..."
/>
{(value.type === 'downstream' || value.type === 'upstream') && (
<FlatSelect
options={FLOW_DISTANCE_OPTIONS}
selectedValues={[value.maxDistanceKm ? String(value.maxDistanceKm) : '']}
onChange={(vals) => {
const km = vals[0] ? parseInt(vals[0]) : undefined;
onChange({ ...value, maxDistanceKm: km });
}}
isMulti={false}
isClearable={false}
searchable={false}
placeholder="Flow distance..."
/>
)}
{value.type === 'near' && (
<FlatSelect
options={NEAR_DISTANCE_OPTIONS}
Expand Down
32 changes: 32 additions & 0 deletions src/components/QueryEditor/StreamFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { StreamFilters as StreamFiltersType } from '../../types/query';
import { FlatSelect } from './FlatSelect/FlatSelect';

interface StreamFiltersProps {
value?: StreamFiltersType;
onChange: (filters: StreamFiltersType) => void;
}

// NHDPlusV2 FTYPE values present on hyf:HY_FlowPath in hydrologykg.
const FTYPE_OPTIONS = [
{ value: 'StreamRiver', label: 'Stream / River' },
{ value: 'ArtificialPath', label: 'Artificial Path' },
{ value: 'CanalDitch', label: 'Canal / Ditch' },
{ value: 'Connector', label: 'Connector' },
{ value: 'Pipeline', label: 'Pipeline' },
];

export function StreamFilters({ value, onChange }: StreamFiltersProps) {
return (
<div className="stream-filters">
<div className="filter-field">
<label>Stream Type:</label>
<FlatSelect
options={FTYPE_OPTIONS}
selectedValues={value?.ftypes ?? []}
onChange={(vals) => onChange({ ...value, ftypes: vals })}
placeholder="Any stream type..."
/>
</div>
</div>
);
}
10 changes: 5 additions & 5 deletions src/constants/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
export const ENDPOINTS = {
sawgraph: 'https://frink.apps.renci.org/sawgraph/sparql',
fiokg: 'https://frink.apps.renci.org/fiokg/sparql',
spatialkg: 'https://frink.apps.renci.org/spatialkg/sparql',
hydrologykg: 'https://frink.apps.renci.org/hydrologykg/sparql',
federation: 'https://frink.apps.renci.org/federation/sparql',
sawgraph: 'https://apps.okn.us/sawgraph/sparql',
fiokg: 'https://apps.okn.us/fiokg/sparql',
spatialkg: 'https://apps.okn.us/spatialkg/sparql',
hydrologykg: 'https://apps.okn.us/hydrologykg/sparql',
federation: 'https://apps.okn.us/federation/sparql',
} as const;

export type EndpointKey = keyof typeof ENDPOINTS;
12 changes: 11 additions & 1 deletion src/engine/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from './templates/fusedQueries';
import {
buildFacilitiesByIri,
buildStreamsByIri,
buildWaterBodiesByIri,
buildWellsByIri,
} from './templates/hydrate';
Expand Down Expand Up @@ -54,6 +55,7 @@ function entityEndpoint(block: EntityBlock): EndpointKey {
return 'sawgraph';
case 'waterBodies':
case 'wells':
case 'streams':
return 'hydrologykg';
}
}
Expand Down Expand Up @@ -102,6 +104,8 @@ function hydrateStep(
return buildWaterBodiesByIri(iris, block.waterBodyFilters);
case 'wells':
return buildWellsByIri(iris, block.wellFilters);
case 'streams':
return buildStreamsByIri(iris, block.streamFilters);
}
},
};
Expand Down Expand Up @@ -149,6 +153,7 @@ function buildFusedSteps(question: AnalysisQuestion): PipelineStep[] {
project,
anchorRegion: anchorRegionOpt,
targetRegion: targetRegionOpt,
maxDistanceKm: relationship.maxDistanceKm,
});
};

Expand All @@ -166,7 +171,11 @@ function buildFusedSteps(question: AnalysisQuestion): PipelineStep[] {
buildQuery: () => buildIriQuery('anchor'),
});

if (relationship.type !== 'near') {
// Supporting stream layer. Skipped when a side is already streams — those
// flowlines come back as the answer set and would be drawn twice.
const streamsAreAnswer =
targetBlock.type === 'streams' || anchorBlock.type === 'streams';
if (relationship.type !== 'near' && !streamsAreAnswer) {
steps.push({
type: 'GET_FLOWLINE_GEOMETRIES',
endpoint: 'federation',
Expand All @@ -177,6 +186,7 @@ function buildFusedSteps(question: AnalysisQuestion): PipelineStep[] {
anchor: anchorBlock,
direction: relationship.type === 'downstream' ? 'downstream' : 'upstream',
anchorIris: ctx.anchorIris,
maxDistanceKm: relationship.maxDistanceKm,
}),
});
}
Expand Down
Loading