-
Notifications
You must be signed in to change notification settings - Fork 1
Dropdowns Substance
Picks which PFAS chemicals a sample query looks for. Multi select. Leaving it empty means "any substance".

It appears in the query editor whenever the entity type is Samples, inside + Add Filters, above the Material dropdown.
The list is built from the data rather than hardcoded. It shows every substance that has at
least one contaminant observation behind it, sorted by how many observations mention it,
with that number in brackets after the name, like PFOA (20,120). Labels prefer the short
acronym, fall back to the full chemical name when a substance has no acronym recorded, then
to the name of the source data parameter it was matched to, and only then to the bare
DTXSID. In current data every one of the 101 substances resolves to a real name, so the
DTXSID step never fires. Counts are comma formatted.
Being multi select, the list is headed by a Select all row carrying the number of options it
would tick, like Select all (79). Type a search term and that number narrows to the matches.
The list is region aware. Pick a state or some counties in the Region selector and it narrows to substances actually observed there, with the counts moving to match. Maine, for example, returns 79 substances. Changing the region triggers a refetch, and each region gets its own cache entry.
When the query returns nothing, two things can happen:
- No region selected. You get seven hardcoded PFAS substances and no counts, so the dropdown is never empty on a cold start.
- A region is selected. You get an empty list, on purpose. "No PFAS data for this county" is real information, and quietly showing national defaults there would be misleading.
Two endpoints, picked by whether a region is set. Without a region it goes to sawgraph.
Once a state is chosen it goes to federation, because region filtering needs the spatial
graph joined in and only federation has both graphs.
SELECT ?substance
(SAMPLE(?_label) AS ?label)
(SAMPLE(?_short) AS ?short_label)
(MIN(?_viaParam) AS ?param_label)
(COUNT(DISTINCT ?observation) AS ?num)
WHERE {
?observation rdf:type coso:ContaminantObservation ;
coso:ofDSSToxSubstance ?substance .
?substance a comptox:ChemicalEntity .
OPTIONAL { ?substance rdfs:label ?_label . }
OPTIONAL { ?substance skos:altLabel ?_short . }
OPTIONAL { ?pL comptox:sameAsDSSToxSubstance ?substance ; rdfs:label ?_viaParam . }
} GROUP BY ?substance
ORDER BY DESC(?num) ?labelReading it a line at a time:
-
?observation rdf:type coso:ContaminantObservationis every recorded measurement in the graph. -
coso:ofDSSToxSubstance ?substanceis the chemical that measurement was for. -
?substance a comptox:ChemicalEntitykeeps only real chemical entities. -
OPTIONAL { rdfs:label ?_label }is the full name, like "Perfluorooctanoic acid". Only 69 of the 101 substances have one. -
OPTIONAL { skos:altLabel ?_short }is the acronym, like "PFOA". Only 25 have one. -
OPTIONAL { ?pL comptox:sameAsDSSToxSubstance ... }reaches back to the source data parameter the substance was matched to, and borrows its name. This is what covers the 32 substances that have no name of their own, taking the list to 101 named out of 101. -
COUNT(DISTINCT ?observation)is the number shown in brackets.
MIN() rather than SAMPLE() on the borrowed name, because 41 substances are matched by
more than one parameter and SAMPLE() would pick a different one from run to run.
Both labels are OPTIONAL on purpose. A required label pattern does not return an unlabelled
substance, it returns nothing at all for that substance, so the row vanishes from the dropdown
and takes its observations with it. That single mistake broke this dropdown twice, first to
zero rows and then to 32 missing substances. The DTXSID fallback in the hook is the last
resort behind the borrowed name, and is unreachable in current data. It stays anyway,
precisely because a missing label must never delete a row.
SAMPLE() is used on both labels because a substance can carry more than one of each and
the dropdown only has room for one. Any of them will do.
A region pattern gets spliced in, restricting observations to sample points inside the chosen state or counties:
SELECT ?substance
(SAMPLE(?_label) AS ?label)
(SAMPLE(?_short) AS ?short_label)
(MIN(?_viaParam) AS ?param_label)
(COUNT(DISTINCT ?observation) AS ?num)
WHERE {
?sp rdf:type coso:SamplePoint .
?sp spatial:connectedTo ?_region .
?_region rdf:type kwg-ont:AdministrativeRegion_3 ;
kwg-ont:administrativePartOf+ ?_regionRoot .
VALUES ?_regionRoot { kwgr:administrativeRegion.USA.23 }
?observation rdf:type coso:ContaminantObservation ;
coso:observedAtSamplePoint ?sp ;
coso:ofDSSToxSubstance ?substance .
?substance a comptox:ChemicalEntity .
OPTIONAL { ?substance rdfs:label ?_label . }
OPTIONAL { ?substance skos:altLabel ?_short . }
OPTIONAL { ?pL comptox:sameAsDSSToxSubstance ?substance ; rdfs:label ?_viaParam . }
} GROUP BY ?substance
ORDER BY DESC(?num) ?labeladministrativeRegion.USA.23 is Maine, FIPS 23. County selections put five digit county
FIPS codes in that same VALUES slot instead, so Cumberland County becomes
administrativeRegion.USA.23005.
The AdministrativeRegion_3 hop plus administrativePartOf+ is what makes one state code
cover everything underneath it. Sample points connect to small regions, and the query walks
up the containment chain until it reaches the state you picked.
Sub county selections, meaning codes longer than five digits, take a different route
entirely. They join with kwg-ont:sfWithin|kwg-ont:sfTouches against a Data Commons geoId
URI rather than walking the administrative hierarchy.
Verified against the live endpoints on 2026-09-09. The Maine query returns 79 substances, topped by PFOA at 20120 observations, PFOS at 19988, PFBS at 19895. Unfiltered returns 101, all of them named, none falling through to a DTXSID.
- The control is a shared
FlatSelect, rendered atSampleFilters.tsx:74. - Its options come from
useSubstances(region), called atSampleFilters.tsx:27. - The query text is built by
buildDiscoverSubstancesQuery(), and the region part bybuildSamplePointRegionPattern(). - It runs through
executeSparql(), against the URLs inendpoints.ts. - Prefixes come from
prefixes.ts. - The fallback list is
FALLBACK_SUBSTANCES.
React Query caches under ['substances', <region key>] with staleTime: Infinity, so it is
one fetch per region per session, and retry: 1. FALLBACK_SUBSTANCES doubles as
placeholderData, which is why the dropdown shows the default PFAS list while a real query
is still in flight.
The label shown in the list is shortLabel || label, with the count appended by withCount()
at SampleFilters.tsx:22.
Selecting an option stores both the substance URI and its display label on the question, so
the Analysis Question sentence can say "PFOA" instead of a DSSTox URI.
The whole priority order, skos:altLabel, then rdfs:label, then the source parameter's
name with ***retired*** resolved, then the DTXSID, lives in substanceLabel() in
src/constants/substances.ts. The map popup's sample table uses the same function.
It did not always. The dropdown had the source parameter step and the popup did not, so the
same substance read as "10-H-Perfluorodecanoic acid" in the dropdown and as a bare
DTXSID10630918 in the popup, which looked like a broken query rather than a missing label.
The popup's query now projects the label parts instead of picking a winner in SPARQL, and
aggregates each one, which also stops a substance with two labels from duplicating its
observation's row. npm run check-substance-labels asserts the order.
Exactly seven PFAS substances and no counts. You are looking at FALLBACK_SUBSTANCES.
Either the endpoint failed, or the query genuinely returned zero rows.
A bare DTXSID instead of a chemical name. This should no longer happen. 32 substances
have no rdfs:label of their own, but all 32 are named by the parameter they were matched
to, so the list resolves 101 out of 101. Seeing a raw DTXSID means a substance now has
neither, which is new data rather than a code bug.
A full chemical name where you expected an acronym. Only 25 substances carry
skos:altLabel, so the other 76 show their full name. The source parameters do carry
acronyms for another 44, but they are deliberately not used: the _A suffix on them marks
the acid as distinct from the anion, so PFOS_A is Perfluorooctanesulfonic acid while
PFOS is Perfluorooctanesulfonate, two different DTXSIDs. Adopting them collapsed 10 pairs
of distinct substances into identical looking rows. The full names keep them apart.
A substance in the list that is not a PFAS. Acetohydroxamic acid shows up with 2
observations. That is an upstream alignment error, not a display bug: the Maine EGAD
parameter me-egad#parameter.PFECHS_A is mapped to DTXSID7022546, which really is
acetohydroxamic acid. Belongs in an issue against
pfas-kg.
Empty after picking a county. There genuinely are no observations there. Expected, not a bug.
Briefly shows the national list, then empties. placeholderData is unconditional, so the
fallback flashes while the region scoped query resolves and is then replaced by the real
result, empty or otherwise.
Full chemical names instead of acronyms. Those substances have no skos:altLabel in the
graph. Nothing to fix in the app.
Stale after changing region. The region is not reaching the hook. Check the region prop
chain into SampleFilters, since the cache key is derived from it.
A note on dcterms:alternative. Earlier versions of this query, and of this page, read the
full name from dcterms:alternative. That predicate has zero triples on substances, on
sawgraph and on federation alike, so the query returned nothing at all and the dropdown sat
on its fallback. An earlier revision of this page blamed the outage on the predicate existing
only on federation. That was wrong, both variants were broken. The 3.8M dcterms:alternative
triples in fiokg are facility alternative names, which is the likely source of the mix up.
Substance names live on rdfs:label, which is what the maintainers' own competency question
CQ2.rq uses.
- Dropdowns, the index of all filter controls
- Dropdowns Material, which shares the same region pattern
-
docs/SCHEMA.mdfor the wider predicate inventory
Dropdowns
Samples
In the repo