From 4d7970361fd85fc1ae324d0e199a2699c1cc2493 Mon Sep 17 00:00:00 2001 From: Prayas Lashkari Date: Thu, 17 Sep 2026 23:19:21 -0400 Subject: [PATCH 1/4] feat(dashboard): pin the four demo questions and prewarm their popups Puts the Proto-OKN close-out demo set at the top of PREBUILT_QUERIES so they are the four landing-page cards, with thumbnails built from live runs. Sample popups previously hit the SPARQL endpoints on every click. Adds an `s:` key namespace in the existing result cache, a read-through in useSampleDetails (any miss or error falls back to the live query, as before), and scripts/warm-sample-details.mts to fill it for the demo questions. --- package.json | 2 + ...cilities-upstream-pfos-york-cumberland.svg | 1 + .../samples-downstream-airports-indiana.svg | 1 + .../thumbs/samples-near-airports-indiana.svg | 1 + .../waterbodies-near-airports-indiana.svg | 1 + scripts/check-cache-key.mts | 19 +++- scripts/warm-sample-details.mts | 106 ++++++++++++++++++ server/src/routes/results.ts | 5 +- src/api/resultCacheClient.ts | 20 +++- src/constants/prebuiltQueries.ts | 99 ++++++++++++++++ src/engine/cacheKey.ts | 13 +++ src/hooks/useSampleDetails.ts | 13 ++- 12 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 public/thumbs/facilities-upstream-pfos-york-cumberland.svg create mode 100644 public/thumbs/samples-downstream-airports-indiana.svg create mode 100644 public/thumbs/samples-near-airports-indiana.svg create mode 100644 public/thumbs/waterbodies-near-airports-indiana.svg create mode 100644 scripts/warm-sample-details.mts diff --git a/package.json b/package.json index 69af60f..080ffe9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "preview": "vite preview", "build-thumbnails": "tsx scripts/build-thumbnails.mts", "warm-cache": "tsx scripts/warm-cache.mts", + "warm-sample-details": "tsx scripts/warm-sample-details.mts", + "check-query-snapshots": "tsx scripts/check-query-snapshots.mts", "check-cache-key": "tsx scripts/check-cache-key.mts", "check-step-labels": "tsx scripts/check-step-labels.mts", "check-trace-direction": "tsx scripts/check-trace-direction.mts", diff --git a/public/thumbs/facilities-upstream-pfos-york-cumberland.svg b/public/thumbs/facilities-upstream-pfos-york-cumberland.svg new file mode 100644 index 0000000..25aa9f0 --- /dev/null +++ b/public/thumbs/facilities-upstream-pfos-york-cumberland.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbs/samples-downstream-airports-indiana.svg b/public/thumbs/samples-downstream-airports-indiana.svg new file mode 100644 index 0000000..31eddd4 --- /dev/null +++ b/public/thumbs/samples-downstream-airports-indiana.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbs/samples-near-airports-indiana.svg b/public/thumbs/samples-near-airports-indiana.svg new file mode 100644 index 0000000..0f52b83 --- /dev/null +++ b/public/thumbs/samples-near-airports-indiana.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbs/waterbodies-near-airports-indiana.svg b/public/thumbs/waterbodies-near-airports-indiana.svg new file mode 100644 index 0000000..3f4f08c --- /dev/null +++ b/public/thumbs/waterbodies-near-airports-indiana.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/check-cache-key.mts b/scripts/check-cache-key.mts index 39d7df0..1bf5992 100644 --- a/scripts/check-cache-key.mts +++ b/scripts/check-cache-key.mts @@ -7,7 +7,7 @@ // too-loose means two different questions share an answer and the map shows data // for a question nobody asked. Hence a runnable check rather than a comment. import assert from 'node:assert/strict'; -import { cacheKey } from '../src/engine/cacheKey'; +import { cacheKey, sampleDetailKey } from '../src/engine/cacheKey'; import { PREBUILT_QUERIES } from '../src/constants/prebuiltQueries'; import type { AnalysisQuestion } from '../src/types/query'; @@ -101,4 +101,21 @@ assert.equal(new Set(keys).size, keys.length, 'dashboard questions must have dis assert.equal(await cacheKey(PREBUILT_QUERIES[0].question), keys[0], 'key must be stable across calls'); checks += 2; +// Sample-point popup keys (warm-sample-details.mts writes these). The filter +// argument is part of the key: the same point under a substance filter is a +// different observation table. +const spA = 'http://w3id.org/sawgraph/v1/me-egad#d.SamplePoint.1'; +const spB = 'http://w3id.org/sawgraph/v1/me-egad#d.SamplePoint.2'; +const pfos = { substances: ['http://w3id.org/DSSTox/v1/DTXSID3031864'] }; +assert.equal(await sampleDetailKey(spA), await sampleDetailKey(spA), 'sample key must be stable'); +assert.notEqual(await sampleDetailKey(spA), await sampleDetailKey(spB), 'different points differ'); +assert.notEqual(await sampleDetailKey(spA), await sampleDetailKey(spA, pfos), 'filters are part of the key'); +assert.equal( + await sampleDetailKey(spA, { substances: [] }), + await sampleDetailKey(spA), + 'an emptied filter must not miss the cache', +); +assert.ok((await sampleDetailKey(spA)).startsWith('s:'), 'sample keys live in the s: namespace'); +checks += 5; + console.log(`cache key: ${checks} checks passed`); diff --git a/scripts/warm-sample-details.mts b/scripts/warm-sample-details.mts new file mode 100644 index 0000000..5ece3a3 --- /dev/null +++ b/scripts/warm-sample-details.mts @@ -0,0 +1,106 @@ +// Warms the popup observation rows for the demo prebuilt questions, so a demo +// does not depend on the SPARQL endpoints answering while someone clicks. +// +// CACHE_WRITE_TOKEN=... API_BASE=https://sawgraph-explorer-api-development.up.railway.app \ +// npx tsx scripts/warm-sample-details.mts [id-substring ...] +// +// Same trusted-writer path as warm-cache.mts: the real query runs here and only +// the built popup payload is uploaded, under the `s:` key namespace. +// +// ponytail: sequential batches, no concurrency. It is a manual pre-demo run. +import { planPipeline } from '../src/engine/planner'; +import { executePipeline } from '../src/engine/executor'; +import { sampleDetailKey } from '../src/engine/cacheKey'; +import { buildSampleDetailsByIri } from '../src/engine/templates/hydrate'; +import { buildSamplePointDetail } from '../src/engine/resultTransformer'; +import { executeSparql } from '../src/engine/sparqlClient'; +import { PREBUILT_QUERIES } from '../src/constants/prebuiltQueries'; +import type { SampleFilters } from '../src/types/query'; + +const API_BASE = (process.env.API_BASE ?? 'http://localhost:3001').replace(/\/$/, ''); +const TOKEN = process.env.CACHE_WRITE_TOKEN; +const BATCH = Number(process.env.BATCH ?? 20); +const MAX_POINTS = Number(process.env.MAX_POINTS ?? 2000); +// Which prebuilts to warm. Defaults to the demo set at the top of the list. +const DEFAULT_IDS = [ + 'samples-near-airports-indiana', + 'facilities-upstream-pfos-york-cumberland', + 'samples-downstream-airports-indiana', +]; +const ids = process.argv.slice(2).length ? process.argv.slice(2) : DEFAULT_IDS; + +if (!TOKEN) { + console.error('CACHE_WRITE_TOKEN is required (must match the API service).'); + process.exit(1); +} + +const targets = PREBUILT_QUERIES.filter((q) => ids.some((id) => q.id.includes(id))); +if (targets.length === 0) { + console.error(`No prebuilt matched ${ids.join(', ')}`); + process.exit(1); +} + +let stored = 0; +let failed = 0; + +for (const prebuilt of targets) { + console.log(`\n${prebuilt.title}`); + const filters: SampleFilters | undefined = + prebuilt.question.blockA.type === 'samples' + ? prebuilt.question.blockA.sampleFilters + : prebuilt.question.blockC.sampleFilters; + + const result = await executePipeline(planPipeline(prebuilt.question), prebuilt.question, () => {}); + if (result.status !== 'success') { + console.log(` pipeline ${result.status}, skipping`); + failed++; + continue; + } + + const iris = [ + ...new Set( + Object.values(result.data) + .flat() + .map((row) => row.sp) + .filter((sp): sp is string => Boolean(sp)), + ), + ].slice(0, MAX_POINTS); + console.log(` ${iris.length} sample point(s)`); + + for (let i = 0; i < iris.length; i += BATCH) { + const chunk = iris.slice(i, i + BATCH); + try { + const rows = await executeSparql('federation', buildSampleDetailsByIri(chunk, filters)); + const bySp = new Map(); + for (const row of rows) { + if (!row.sp) continue; + const arr = bySp.get(row.sp); + if (arr) arr.push(row); + else bySp.set(row.sp, [row]); + } + + for (const [sp, spRows] of bySp) { + const detail = buildSamplePointDetail(spRows); + if (!detail) continue; + const res = await fetch(`${API_BASE}/api/results/${await sampleDetailKey(sp, filters)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-Cache-Token': TOKEN }, + body: JSON.stringify({ question: { samplePointIri: sp, filters: filters ?? null }, result: detail }), + }); + if (res.ok) stored++; + else { + failed++; + console.log(` upload failed for ${sp} (${res.status})`); + } + } + } catch (err) { + failed++; + console.log(` batch ${i} failed: ${err instanceof Error ? err.message.slice(0, 70) : String(err)}`); + } + process.stdout.write(`\r ${Math.min(i + BATCH, iris.length)}/${iris.length} fetched, ${stored} cached`); + } + process.stdout.write('\n'); +} + +console.log(`\n${stored} sample point(s) cached, ${failed} failure(s)`); +process.exit(stored === 0 ? 1 : 0); diff --git a/server/src/routes/results.ts b/server/src/routes/results.ts index 06aee6e..f2c1f80 100644 --- a/server/src/routes/results.ts +++ b/server/src/routes/results.ts @@ -43,8 +43,9 @@ resultsRouter.put('/:key', largeJson, async (req: Request, res: Response) => { if (!body?.question || !body?.result) { return res.status(400).json({ error: 'question and result are required' }); } - if (!key.startsWith('q:')) { - return res.status(400).json({ error: 'key must be in the q: namespace' }); + // q: a whole pipeline result, s: one sample point's popup rows. + if (!key.startsWith('q:') && !key.startsWith('s:')) { + return res.status(400).json({ error: 'key must be in the q: or s: namespace' }); } const serialized = JSON.stringify(body.result); diff --git a/src/api/resultCacheClient.ts b/src/api/resultCacheClient.ts index 1802dc4..1f59ced 100644 --- a/src/api/resultCacheClient.ts +++ b/src/api/resultCacheClient.ts @@ -1,7 +1,8 @@ import type { PipelineSuccess } from '../engine/executor'; -import { cacheKey } from '../engine/cacheKey'; +import { cacheKey, sampleDetailKey } from '../engine/cacheKey'; import { fromWire, isWireResult, toWire, type WireResult } from '../engine/wire'; import type { AnalysisQuestion } from '../types/query'; +import type { SamplePointDetail } from '../types/map'; function getApiBase(): string { const base = import.meta.env.VITE_API_BASE_URL; @@ -76,3 +77,20 @@ export async function savePublishedResult( return false; } } + +// One sample point's popup rows, if a prewarm run stored them. Any failure is +// a miss: the caller then queries the endpoint live, as it always did. +export async function fetchCachedSampleDetail( + samplePointIri: string, + filters?: unknown, +): Promise { + if (cacheDisabled()) return null; + try { + const key = await sampleDetailKey(samplePointIri, filters); + const res = await fetch(`${getApiBase()}/api/results/${key}`); + if (!res.ok) return null; + return (await res.json()) as SamplePointDetail; + } catch { + return null; + } +} diff --git a/src/constants/prebuiltQueries.ts b/src/constants/prebuiltQueries.ts index 0178784..b72bb08 100644 --- a/src/constants/prebuiltQueries.ts +++ b/src/constants/prebuiltQueries.ts @@ -14,6 +14,105 @@ export interface PrebuiltQuery { // the thing being asked about — rather than four variations on "samples near // facilities", which is what a reader took to be the whole tool. export const PREBUILT_QUERIES: PrebuiltQuery[] = [ + // Demo set (Proto-OKN close-out): pinned to the top so they are the four + // cards on the landing page. Their results and sample popups are prewarmed. + // Remove this block after the demo to restore the ordering described above. + { + id: 'samples-near-airports-indiana', + title: 'Samples Near Airport Facilities in Indiana', + description: + 'Find PFAS sample points within roughly two miles of Other Airport Operations (NAICS 488119) and Scheduled Passenger Air Transportation (NAICS 481111) facilities across Indiana.', + tags: ['Samples', 'Facilities', 'Near', 'Indiana', 'Airports'], + question: { + blockA: { type: 'samples', region: { stateCode: '18' } }, + relationship: { type: 'near', hops: 2 }, + blockC: { + type: 'facilities', + facilityFilters: { + industryCodes: ['488119', '481111'], + industryLabels: { + '488119': 'Other Airport Operations', + '481111': 'Scheduled Passenger Air Transportation', + }, + }, + }, + }, + }, + { + id: 'waterbodies-near-airports-indiana', + title: 'Surface Water Bodies Near Airport Facilities in Indiana', + description: + 'Locate surface water bodies within roughly a mile of Other Airport Operations (NAICS 488119) and Scheduled Passenger Air Transportation (NAICS 481111) facilities across Indiana.', + tags: ['Surface Water Bodies', 'Facilities', 'Near', 'Indiana', 'Airports'], + question: { + blockA: { type: 'waterBodies', region: { stateCode: '18' } }, + relationship: { type: 'near', hops: 1 }, + blockC: { + type: 'facilities', + facilityFilters: { + industryCodes: ['488119', '481111'], + industryLabels: { + '488119': 'Other Airport Operations', + '481111': 'Scheduled Passenger Air Transportation', + }, + }, + }, + }, + }, + { + id: 'facilities-upstream-pfos-york-cumberland', + title: 'Facilities Upstream from PFOS Samples in York and Cumberland Counties', + description: + 'Given samples that tested positive for PFOS in York and Cumberland counties, Maine, trace the river network back the other way: which facilities in those counties sit upstream, and are therefore candidate sources.', + tags: ['Facilities', 'Samples', 'Upstream', 'PFOS', 'Maine', 'York', 'Cumberland'], + question: { + blockA: { + type: 'facilities', + region: { + stateCode: '23', + countyCodes: ['23031', '23005'], + countyLabels: { '23031': 'York County, Maine', '23005': 'Cumberland County, Maine' }, + }, + }, + relationship: { type: 'upstream' }, + blockC: { + // Both blocks carry the counties, as in the PFHpA question below: the + // scope belongs to the samples, and mirroring it keeps the trace from + // reaching every PFOS sample in the graph. + type: 'samples', + region: { + stateCode: '23', + countyCodes: ['23031', '23005'], + countyLabels: { '23031': 'York County, Maine', '23005': 'Cumberland County, Maine' }, + }, + sampleFilters: { + substances: ['http://w3id.org/DSSTox/v1/DTXSID3031864'], + substanceLabels: { 'http://w3id.org/DSSTox/v1/DTXSID3031864': 'PFOS' }, + }, + }, + }, + }, + { + id: 'samples-downstream-airports-indiana', + title: 'Indiana Samples Downstream of Airports & Air Transportation Sites', + description: + 'Trace downstream flow paths from Other Airport Operations (NAICS 488119) and Scheduled Passenger Air Transportation (NAICS 481111) facilities in Indiana to find PFAS sample points in potentially affected areas.', + tags: ['Samples', 'Facilities', 'Downstream', 'Indiana', 'Airports'], + question: { + blockA: { type: 'samples', region: { stateCode: '18' } }, + relationship: { type: 'downstream' }, + blockC: { + type: 'facilities', + facilityFilters: { + industryCodes: ['488119', '481111'], + industryLabels: { + '488119': 'Other Airport Operations', + '481111': 'Scheduled Passenger Air Transportation', + }, + }, + }, + }, + }, { id: 'facilities-upstream-pfhpa-gw-cumberland', title: 'Facilities Upstream from PFHpA Groundwater Contamination in Cumberland County', diff --git a/src/engine/cacheKey.ts b/src/engine/cacheKey.ts index 935b056..fb31917 100644 --- a/src/engine/cacheKey.ts +++ b/src/engine/cacheKey.ts @@ -92,3 +92,16 @@ export async function cacheKey(question: AnalysisQuestion): Promise { const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); return `q:${hex}`; } + +// Key for one sample point's observation table (the popup rows), cached +// server-side so a demo does not depend on the SPARQL endpoints answering on +// click. Same versioning as the question key: a graph reload retires both. +export async function sampleDetailKey( + samplePointIri: string, + filters?: unknown, +): Promise { + const body = `${DATA_VERSION}|${samplePointIri}|${JSON.stringify(canonical(filters ?? {}) ?? {})}`; + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)); + const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); + return `s:${hex}`; +} diff --git a/src/hooks/useSampleDetails.ts b/src/hooks/useSampleDetails.ts index 1acd855..27558ed 100644 --- a/src/hooks/useSampleDetails.ts +++ b/src/hooks/useSampleDetails.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { executeSparql } from '../engine/sparqlClient'; import { buildSampleDetailsByIri } from '../engine/templates/hydrate'; import { buildSamplePointDetail } from '../engine/resultTransformer'; +import { fetchCachedSampleDetail } from '../api/resultCacheClient'; import type { SamplePointDetail } from '../types/map'; import type { SampleFilters } from '../types/query'; @@ -11,6 +12,9 @@ import type { SampleFilters } from '../types/query'; // (37,425 rows for one Maine question) to fill popups that open one at a time. // One sample point is ~30KB. `enabled` is driven by the popup's open state so // nothing is fetched until a user actually asks for it. +// +// Prewarmed popups (scripts/warm-sample-details.mts) are served from the API +// cache; everything else falls through to the live endpoint as before. export function useSampleDetails( samplePointIri: string | null, enabled: boolean, @@ -22,10 +26,11 @@ export function useSampleDetails( staleTime: Infinity, retry: false, queryFn: async () => { - const rows = await executeSparql( - 'federation', - buildSampleDetailsByIri([samplePointIri as string], filters), - ); + const iri = samplePointIri as string; + const cached = await fetchCachedSampleDetail(iri, filters); + if (cached) return cached; + + const rows = await executeSparql('federation', buildSampleDetailsByIri([iri], filters)); return buildSamplePointDetail(rows); }, }); From 233076710ed051df11a711840fbbca7579d59d96 Mon Sep 17 00:00:00 2001 From: Prayas Lashkari Date: Thu, 17 Sep 2026 23:26:09 -0400 Subject: [PATCH 2/4] fix(api): raise the result-cache ceiling to 32MB The Indiana water-bodies-near-airports demo question serialises to 26.6MB of polygon geometry and was rejected by the route body limit (a bare 413 from express, before our own size check). --- server/src/resultCache.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/resultCache.ts b/server/src/resultCache.ts index 0e0fbd4..fac1a29 100644 --- a/server/src/resultCache.ts +++ b/server/src/resultCache.ts @@ -3,9 +3,11 @@ import { pool } from './db.js'; // Payload budget for a single cached result. Measured pipelines run 3-7MB of // JSON once the internal IRI lists are trimmed client-side; the largest observed -// (a statewide downstream trace with 15MB of river geometry) reached 21MB. +// (a statewide downstream trace with 15MB of river geometry) reached 21MB, and +// the Indiana water-bodies-near-airports demo question 26.6MB of polygons. // Anything past this is skipped rather than stored — publishing still succeeds. -export const MAX_RESULT_BYTES = 25 * 1024 * 1024; +// Stored gzipped, so the row itself is a fraction of this. +export const MAX_RESULT_BYTES = 32 * 1024 * 1024; // A clean result is stable until the graph reloads. A partial one is not: it // means slices failed, and a warmer engine may answer them, so it must not be From 8c34f8e6d34a123cc7faec80087b8db9d3df3f84 Mon Sep 17 00:00:00 2001 From: Prayas Lashkari Date: Fri, 18 Sep 2026 00:22:29 -0400 Subject: [PATCH 3/4] fix(map): sample popup laid out for the placeholder on first open Leaflet measures a popup when it opens; react-leaflet calls update() on popupopen and never again. The observation rows arrive after that, so the first open kept the placeholder's size and anchor and the content spilled out of the box. The second open looked right only because React Query already had the rows. --- src/components/Map/MapPopup.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/Map/MapPopup.tsx b/src/components/Map/MapPopup.tsx index 7c48580..a96a594 100644 --- a/src/components/Map/MapPopup.tsx +++ b/src/components/Map/MapPopup.tsx @@ -1,3 +1,6 @@ +import { useEffect } from 'react'; +import { useMap } from 'react-leaflet'; +import { Popup as LeafletPopup } from 'leaflet'; import type { MapFeature, SamplePointDetail, SampleRecord } from '../../types/map'; import { useSampleDetails } from '../../hooks/useSampleDetails'; import { useQueryStore } from '../../store/queryStore'; @@ -18,6 +21,19 @@ function SamplePopup({ feature, isOpen }: { feature: MapFeature; isOpen: boolean : question.blockC.sampleFilters; const { data, isLoading, isError } = useSampleDetails(feature.id, isOpen, sampleFilters); const props = feature.properties; + const map = useMap(); + + // Leaflet measures a popup once, when it opens (react-leaflet calls + // instance.update() on popupopen and never again). Our rows arrive after + // that, so the first open is sized and anchored for the placeholder and the + // real content spills out of the box. Opening it a second time looks fine + // only because React Query already has the rows. Re-measure when they land. + useEffect(() => { + if (!data) return; + map.eachLayer((layer) => { + if (layer instanceof LeafletPopup) layer.update(); + }); + }, [data, map]); if (data) { return ( From e9d255ca6d8b87ce376e944887f3dcf9a6258df4 Mon Sep 17 00:00:00 2001 From: Prayas Lashkari Date: Sun, 20 Sep 2026 11:10:26 -0400 Subject: [PATCH 4/4] fix(dashboard): leave facilities unconstrained in the PFOS upstream question The demo question asked for facilities in York and Cumberland that are upstream of PFOS samples in those same counties. An upstream source can sit outside the county its sample is in, so the county scope belongs to the samples alone. Both this and samples-downstream-airports-indiana now lead with the target under development's narrowness ranking; check-query-joins expects that. Thumbnail rebuilt from a live run (8670 points, 3925 lines). --- .../facilities-upstream-pfos-york-cumberland.svg | 2 +- scripts/check-query-joins.mts | 15 ++++++++++++++- src/constants/prebuiltQueries.ts | 16 ++++------------ 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/public/thumbs/facilities-upstream-pfos-york-cumberland.svg b/public/thumbs/facilities-upstream-pfos-york-cumberland.svg index 25aa9f0..4b9f716 100644 --- a/public/thumbs/facilities-upstream-pfos-york-cumberland.svg +++ b/public/thumbs/facilities-upstream-pfos-york-cumberland.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/scripts/check-query-joins.mts b/scripts/check-query-joins.mts index 1d659c6..b8bf432 100644 --- a/scripts/check-query-joins.mts +++ b/scripts/check-query-joins.mts @@ -311,7 +311,20 @@ for (const targetType of ENTITY_TYPES) { // facilities either way, with the reordered form no slower (13s and 2s against // 16s and 3s). It reorders because the rule says the state-scoped side is the // narrower one, and the measurement agrees. -const EXPECTED_REORDER = new Set(['samples-downstream-waste-indiana']); +// +// `facilities-upstream-pfos-york-cumberland` is the same shape the other way +// round: facilities are unconstrained on block A, and block C is PFOS samples +// in two Maine counties. Leading with the samples is rule 1 working, not a +// regression. +// +// `samples-downstream-airports-indiana` is the same shape as the waste one: +// Indiana samples on block A, NAICS 488119/481111 with no region on block C. +// It started reordering when the ranking landed on development. +const EXPECTED_REORDER = new Set([ + 'samples-downstream-waste-indiana', + 'samples-downstream-airports-indiana', + 'facilities-upstream-pfos-york-cumberland', +]); for (const prebuilt of PREBUILT_QUERIES) { const question: AnalysisQuestion = prebuilt.question; diff --git a/src/constants/prebuiltQueries.ts b/src/constants/prebuiltQueries.ts index b72bb08..d5f2505 100644 --- a/src/constants/prebuiltQueries.ts +++ b/src/constants/prebuiltQueries.ts @@ -63,22 +63,14 @@ export const PREBUILT_QUERIES: PrebuiltQuery[] = [ id: 'facilities-upstream-pfos-york-cumberland', title: 'Facilities Upstream from PFOS Samples in York and Cumberland Counties', description: - 'Given samples that tested positive for PFOS in York and Cumberland counties, Maine, trace the river network back the other way: which facilities in those counties sit upstream, and are therefore candidate sources.', + 'Given samples that tested positive for PFOS in York and Cumberland counties, Maine, trace the river network back the other way: which facilities sit upstream of them, and are therefore candidate sources.', tags: ['Facilities', 'Samples', 'Upstream', 'PFOS', 'Maine', 'York', 'Cumberland'], question: { - blockA: { - type: 'facilities', - region: { - stateCode: '23', - countyCodes: ['23031', '23005'], - countyLabels: { '23031': 'York County, Maine', '23005': 'Cumberland County, Maine' }, - }, - }, + // Facilities are left unconstrained: the counties scope the samples, and + // an upstream source can sit outside the county the sample is in. + blockA: { type: 'facilities' }, relationship: { type: 'upstream' }, blockC: { - // Both blocks carry the counties, as in the PFHpA question below: the - // scope belongs to the samples, and mirroring it keeps the trace from - // reaching every PFOS sample in the graph. type: 'samples', region: { stateCode: '23',