diff --git a/Dockerfile b/Dockerfile index 64dcea1d..223a3872 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,17 @@ FROM node:22-slim AS builder WORKDIR /gen3 +ARG NEXT_PUBLIC_GEN3_API +ARG NEXT_PUBLIC_GEN3_DOMAIN +ARG NEXT_PUBLIC_GEN3_LOOM_API +ARG NEXT_PUBLIC_GEN3_REDIRECT_URL +ARG NEXT_PUBLIC_GEN3_COMMONS_NAME=cbds +ENV NEXT_PUBLIC_GEN3_API=$NEXT_PUBLIC_GEN3_API +ENV NEXT_PUBLIC_GEN3_DOMAIN=$NEXT_PUBLIC_GEN3_DOMAIN +ENV NEXT_PUBLIC_GEN3_LOOM_API=$NEXT_PUBLIC_GEN3_LOOM_API +ENV NEXT_PUBLIC_GEN3_REDIRECT_URL=$NEXT_PUBLIC_GEN3_REDIRECT_URL +ENV NEXT_PUBLIC_GEN3_COMMONS_NAME=$NEXT_PUBLIC_GEN3_COMMONS_NAME + # Copy everything COPY . . @@ -42,4 +53,4 @@ ENV NODE_ENV=production ENV PORT=3000 EXPOSE 3000 -CMD ["/bin/bash", "./start.sh"] \ No newline at end of file +CMD ["/bin/bash", "./start.sh"] diff --git a/docs/Configuration/Query.md b/docs/Configuration/Query.md new file mode 100644 index 00000000..275320d5 --- /dev/null +++ b/docs/Configuration/Query.md @@ -0,0 +1,64 @@ +# Query configuration + +Query configuration uses version 2 endpoint and mode records. The page loads +the configured service, fetches its GraphQL schema, and uses the selected mode +to provide its default query and variables. + +```json +{ + "version": 2, + "endpoints": { + "loomGraph": { + "url": "/loom/graphql/graph", + "service": "loom", + "surface": "graph" + }, + "loomFlat": { + "url": "/loom/graphql/flat", + "service": "loom", + "surface": "flat" + } + }, + "modes": [ + { + "id": "loom-fhir-graph", + "label": "Loom Graph", + "endpoint": "loomGraph", + "preset": "loom-fhir-graph" + }, + { + "id": "loom-fhir-dataframe", + "label": "Loom Dataframe", + "endpoint": "loomGraph", + "preset": "loom-fhir-dataframe" + }, + { + "id": "loom-flat", + "label": "Loom Flat", + "endpoint": "loomFlat", + "preset": "loom-flat" + } + ], + "defaultMode": "loom-flat" +} +``` + +Supported services and presets are: + +- `loom-fhir-graph`: binds the selected `PROGRAM-PROJECT` to `input.project`. +- `loom-fhir-dataframe`: binds the selected `PROGRAM-PROJECT` to `input.project`. +- `loom-flat`: adds a `project_id EQ PROGRAM-PROJECT` filter to the flat input. +- `guppy-flat`: adds `auth_resource_path` using `/programs/PROGRAM/projects/PROJECT`. +- `generic`: executes the configured query without automatic project binding. + +The Query page reads the canonical project selector from `project_id`. The +legacy `project` query parameter is accepted once and rewritten to +`project_id`. Accessible projects come from Gecko, and the Run button is +disabled when a project-bound mode has no valid accessible project selected. + +Endpoint URLs may be absolute HTTP/HTTPS URLs or root-relative paths. URLs +with credentials, fragments, protocol-relative syntax, or unsafe schemes are +rejected. Loom graph and flat endpoints must use their matching GraphQL path. + +Older configurations containing only `graphQLEndpoint` are normalized locally +for compatibility. New configuration should use version 2. diff --git a/docs/loom-etl-work-packages/EXPLORER_LOOM_MAPPING_INVENTORY.md b/docs/loom-etl-work-packages/EXPLORER_LOOM_MAPPING_INVENTORY.md new file mode 100644 index 00000000..e6e3bdfa --- /dev/null +++ b/docs/loom-etl-work-packages/EXPLORER_LOOM_MAPPING_INVENTORY.md @@ -0,0 +1,27 @@ +# Explorer read-path inventory + +The Explorer configuration still uses its established `guppyConfig.dataType` +values. The adapter boundary is `packages/core/src/features/loom/mapping.ts`; +all requests below use the canonical value returned by `toLoomDataType`. + +| Explorer surface | Previous request | Loom request | Existing consumer shape | +| --- | --- | --- | --- | +| `pages/Explorer/data.ts` shared-field discovery | Guppy `_mapping` | `dataframeDataset` column metadata | `SharedFieldMapping` | +| `CohortPanel` facets | Guppy grouped aggregations | `dataframeAggregate` COUNT by field | `AggregationsData` | +| `CohortPanel` total | Guppy count aggregation | `dataframeAggregate` COUNT | `number` | +| `ExplorerTable` rows | Guppy offset rows/count | `dataframeRows` with opaque cursor | `JSONObject[]`, row count | +| `QueryRowDetailsPanel` | Guppy single-row query | `dataframeRows` with an ID filter | one `JSONObject` | +| download actions | Guppy download endpoint | Loom dataframe export | JSON/CSV/TSV blob or JSON rows | + +The canonical mapping is: + +- `file` and `document_reference` -> `DocumentReference` +- `research_subject` -> `ResearchSubject` +- `specimen` -> `Specimen` +- `medication_administration` -> `MedicationAdministration` +- `group_member` -> `GroupMember` + +Loom metadata is also the availability boundary. Explorer distinguishes an +unsupported type, transport/authorization failure, missing dataset, a +non-`READY` dataset, and a `READY` dataset with zero rows. There is no Guppy +runtime fallback. diff --git a/docs/loom-etl-work-packages/EXPLORER_LOOM_MAPPING_PLAN.md b/docs/loom-etl-work-packages/EXPLORER_LOOM_MAPPING_PLAN.md new file mode 100644 index 00000000..0ca3c58b --- /dev/null +++ b/docs/loom-etl-work-packages/EXPLORER_LOOM_MAPPING_PLAN.md @@ -0,0 +1,383 @@ +# Explorer-to-Loom dataset mapping plan + +## Objective + +Migrate Explorer reads from the legacy flat metadata API to Loom's published +dataframe API without changing Loom's canonical recipe output names. + +Explorer may continue to use its established data-type vocabulary in routes, +configuration, labels, and component props. A single frontend-owned adapter +will translate those values into Loom `dataType` values before calling the Loom +fetch hooks. + +This is an Explorer integration change. It is not a missing Loom recipe or +publication feature. + +## Decision + +Loom's default recipe output names remain canonical: + +| Explorer or legacy data type | Loom `dataType` | +| --- | --- | +| `file` | `DocumentReference` | +| `document_reference` | `DocumentReference` | +| `research_subject` | `ResearchSubject` | +| `specimen` | `Specimen` | +| `medication_administration` | `MedicationAdministration` | +| `group_member` | `GroupMember` | + +Both `file` and `document_reference` map to `DocumentReference` because the +legacy ETL index name and Explorer's user-facing concept are not necessarily +the same string. The Explorer checkout must verify which form is used at each +current call site before obsolete forms are removed. + +There is no separate `publication` output in the default recipe. Publication +is the operation that makes the five outputs above available through Loom. No +`Publication` dataset or output alias should be invented. + +## Ownership boundary + +### Explorer owns + +- the legacy-to-canonical data-type map; +- selection of the correct Loom hook for each Explorer read; +- conversion of Explorer filters, sorting, pagination, and facets into Loom + query inputs; +- adaptation of Loom responses into existing Explorer view models; +- user-facing fallback, empty, loading, and error states; +- migration and removal of the old Explorer read path. + +### Loom owns + +- canonical output names from the materialized recipe; +- discovery of published datasets; +- principal-scoped project federation; +- active-generation resolution; +- row authorization; +- ClickHouse row, filter, sort, cursor, and aggregation execution; +- publication readiness and error reporting. + +### The ETL job owns + +- loading complete generations into Loom; +- running and waiting for the default recipe materialization; +- failing the job if publication does not reach `READY`. + +The ETL job does not publish Explorer-specific aliases or configure frontend +data-type mappings. + +## Loom API contract used by Explorer + +Explorer should use the projectless, principal-scoped GraphQL read surface: + +```graphql +dataframeDatasets +dataframeDataset(input: { dataType: $dataType }) +dataframeRows(input: { + dataType: $dataType + columns: $columns + filters: $filters + sort: $sort + first: $first + after: $after +}) +dataframeAggregate(input: { + dataType: $dataType + groupBy: $groupBy + filters: $filters + operation: $operation + column: $column +}) +``` + +Explorer supplies a canonical `dataType` after applying its map. It must not +supply a project or generation. Loom derives authorized projects from the +authenticated principal, resolves active generations, and federates matching +published outputs. + +## Work package 1: inventory the current Explorer contract + +Before editing hooks, trace the current Explorer read path from rendered page +to network request. + +1. Locate every route, configuration value, component prop, selector, and hook + that passes a data-type value. +2. Record the actual values in use, including `file`, `document_reference`, and + `research_subject`. +3. Locate the current hooks for: + - schema or field discovery; + - rows; + - total counts; + - facets or grouped counts; + - filter submission; + - sort submission; + - cursor or offset pagination. +4. Record the response shape consumed by each component. +5. Identify whether the new Loom hooks already expose those operations or need + a thin wrapper. + +Deliverable: a call-site inventory that identifies the one adapter boundary +through which all Explorer data types can pass. + +Acceptance criteria: + +- no Explorer metadata request path remains unaccounted for; +- the real network-emitting functions are identified; +- the inventory distinguishes user-facing labels from API data-type values; +- existing hook consumers and their required return shapes are listed. + +## Work package 2: add the canonical mapping module + +Create one mapping module adjacent to the Loom hooks, not inside individual +pages or components. + +The module should expose a closed input type where practical and one resolver: + +```ts +export const EXPLORER_TO_LOOM_DATA_TYPE = { + file: 'DocumentReference', + document_reference: 'DocumentReference', + research_subject: 'ResearchSubject', + specimen: 'Specimen', + medication_administration: 'MedicationAdministration', + group_member: 'GroupMember', +} as const; + +export function toLoomDataType(dataType: ExplorerDataType): LoomDataType { + return EXPLORER_TO_LOOM_DATA_TYPE[dataType]; +} +``` + +Use the frontend's generated GraphQL types for `LoomDataType` if they provide a +useful type. Otherwise define the canonical values locally from the map rather +than accepting arbitrary strings. + +Unknown legacy values must fail visibly during development. Do not silently +pass an unknown value to Loom and do not default it to `DocumentReference`. + +Acceptance criteria: + +- all mappings live in one module; +- the map contains only real default recipe outputs; +- unknown values produce a deterministic error or explicit unsupported state; +- unit tests cover every entry and the unknown-value behavior; +- no Loom backend change is required. + +## Work package 3: route Loom fetch hooks through the mapping + +Apply `toLoomDataType` at the outermost hook boundary that still receives an +Explorer data type. Components should not know Loom recipe output names unless +they already operate directly on Loom metadata. + +For each hook: + +1. Accept the existing Explorer-facing data type. +2. Resolve it once to the canonical Loom `dataType`. +3. Use that canonical value consistently for dataset metadata, rows, and + aggregate requests. +4. Include the original Explorer value in client-side diagnostics while + avoiding sensitive request data. +5. Keep query-cache keys unambiguous. Prefer the canonical Loom value in cache + keys so `file` and `document_reference` share the same dataset cache entry. + +Do not map column names. Dataset aliases and dataframe column names are +different contracts; column compatibility must be assessed separately from +this plan. + +Acceptance criteria: + +- `file` emits `dataType: "DocumentReference"` for dataset, row, and aggregate + requests; +- `research_subject` emits `dataType: "ResearchSubject"` for all three; +- aliases that map to the same Loom output do not create inconsistent caches; +- components retain their current labels and route vocabulary; +- no request sends a legacy alias to Loom. + +## Work package 4: adapt Explorer operations to Loom + +### Dataset and field discovery + +Use `dataframeDataset` to retrieve publication metadata and columns. Convert +`DataframeColumn` values into the field model expected by Explorer, preserving: + +- name; +- logical type; +- nullability and repeatedness; +- filterable, sortable, and aggregatable capabilities. + +Explorer should disable unsupported controls based on these capabilities +instead of issuing invalid requests. + +### Rows + +Use `dataframeRows` with the canonical data type. Preserve the existing table +contract by adapting: + +- returned column order; +- JSON row values; +- total count, when supplied; +- `pageInfo.hasNextPage`; +- `pageInfo.endCursor`. + +The adapter should treat `endCursor` as opaque. It must not parse it or convert +it into an offset. + +### Filters and sorting + +Create explicit conversion functions from Explorer filter state to +`DataframeFilterInput` and from Explorer sort state to `DataframeSortInput`. +Reject unsupported operators before the request and surface a usable UI error. + +Do not infer filter behavior from display labels. Always use Loom column names +from dataset metadata. + +### Facets and counts + +Use `dataframeAggregate` for grouped counts and other supported aggregations. +Keep the same canonical data type and active filter set used by the row query. +Define whether a facet excludes its own active filter based on current Explorer +behavior, then preserve that behavior explicitly in the adapter. + +Acceptance criteria: + +- the Explorer table can load, filter, sort, and paginate Loom rows; +- facets and displayed totals are calculated over the same authorization scope + as rows; +- unsupported column operations are disabled or rejected before querying; +- cursor pagination works across at least two pages without duplicates or + omissions; +- changing the Explorer data type resets incompatible filters and cursors. + +## Work package 5: handle readiness and partial availability + +Explorer must distinguish these cases: + +1. Loom is unreachable or returns a transport error. +2. The user is unauthenticated or has no authorized projects. +3. No active publication exists for the mapped data type. +4. A publication exists but is not `READY`. +5. The publication is `READY` but contains zero rows. +6. The requested legacy data type is unsupported by the mapping. + +Use `dataframeDatasets` or `dataframeDataset` as the availability source. Do not +fall back silently to the old backend after a Loom error; that could show data +from a different generation or authorization path. + +Acceptance criteria: + +- each state has a deterministic hook result and user-visible behavior; +- an empty ready dataset is not reported as a missing publication; +- an unsupported mapping is distinguishable from a missing Loom dataset; +- retries do not mix cursors or rows from different data types. + +## Work package 6: tests + +### Mapping unit tests + +- assert every table entry; +- assert both `file` and `document_reference` map to `DocumentReference`; +- assert unsupported values fail explicitly; +- assert the map contains the five canonical default recipe outputs. + +### Hook request-contract tests + +Mock the GraphQL boundary and verify exact variables for: + +- dataset discovery; +- first row page; +- next row page; +- filters; +- ascending and descending sort; +- grouped count; +- filtered grouped count. + +At minimum, cover `file`, `research_subject`, and one direct snake_case to +PascalCase mapping. + +### Hook response-adapter tests + +- column capabilities; +- empty rows; +- null and repeated values; +- total count present and absent; +- final and non-final pages; +- Loom GraphQL errors; +- missing publication; +- stale request completion after switching data types. + +### Explorer integration tests + +For one representative Explorer page: + +1. render with the legacy `file` concept; +2. verify the network request uses `DocumentReference`; +3. render returned columns and rows; +4. apply a filter and sort; +5. request a second page; +6. render a facet count; +7. switch to `research_subject` and verify state reset. + +### Loom contract check + +Add or retain a Loom-side test proving that canonical output names can resolve +through `dataframeDataset`, `dataframeRows`, and `dataframeAggregate`. This is a +contract check, not an alias feature. + +## Work package 7: rollout + +1. Land the mapping and hook tests without changing the active Explorer source. +2. Add a temporary frontend data-source switch if Explorer does not already + have one. +3. Enable Loom in a development environment with a known `READY` default + recipe publication. +4. Compare visible rows, totals, facets, filters, sorting, and pagination for + the representative Explorer pages. +5. Validate with users authorized for one project, multiple projects, and no + projects. +6. Make Loom the default after acceptance checks pass. +7. Remove the old fetch path and temporary switch in a follow-up change after a + short observation window. + +The switch controls the frontend read implementation only. It must not change +recipe names, ETL publication behavior, or Loom aliases. + +## Observability + +During rollout, record non-sensitive client telemetry for: + +- original Explorer data type; +- mapped Loom data type; +- operation category: dataset, rows, or aggregate; +- success, unsupported mapping, missing publication, authorization failure, or + transport failure; +- request duration. + +Do not log row contents, filter values, authorization paths, or access tokens. + +## Explicit non-goals + +- adding `file`, `research_subject`, or other legacy aliases to Loom bundle + publication; +- renaming Loom's default recipe outputs; +- creating a `Publication` dataframe output; +- changing ETL generation loading or recipe materialization; +- redesigning Explorer routes, labels, or page layout; +- preserving the old backend as an automatic runtime fallback; +- solving dataframe column-name parity inside the dataset-name mapping layer. + +## Definition of done + +The mapping migration is complete when: + +1. Explorer can request all five default metadata datasets through its existing + concepts while every Loom request uses a canonical recipe output name. +2. The representative Explorer flows load rows, filters, sorting, pagination, + totals, and facets from Loom. +3. Authorization and multi-project federation are left to Loom and verified in + integration testing. +4. Missing, empty, unauthorized, unsupported, and failed states are distinct. +5. Mapping, hook-contract, response-adapter, and representative UI tests pass. +6. No Loom publication alias was added for Explorer compatibility. +7. The old Explorer read path can be removed without changing the Loom recipe + or ETL job. + diff --git a/package-lock.json b/package-lock.json index 2524a732..9b839c1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", + "@typescript/native": "npm:typescript@^7.0.2", "@welldone-software/why-did-you-render": "^10.0.1", "autoprefixer": "^10.4.24", "eslint": "^9.10.0", @@ -126,6 +127,8 @@ }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", "license": "MIT", "peer": true, "engines": { @@ -1916,6 +1919,8 @@ }, "node_modules/@choojs/findup": { "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", + "integrity": "sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==", "license": "MIT", "peer": true, "dependencies": { @@ -1927,6 +1932,8 @@ }, "node_modules/@choojs/findup/node_modules/commander": { "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT", "peer": true }, @@ -5989,6 +5996,8 @@ }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", "license": "ISC", "peer": true, "dependencies": { @@ -6001,18 +6010,25 @@ }, "node_modules/@mapbox/geojson-types": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", "license": "ISC", "peer": true }, "node_modules/@mapbox/jsonlint-lines-primitives": { - "version": "2.0.2", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", "peer": true, "engines": { - "node": ">= 0.6" + "node": ">= 22" } }, "node_modules/@mapbox/mapbox-gl-supported": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", "license": "BSD-3-Clause", "peer": true, "peerDependencies": { @@ -6021,21 +6037,29 @@ }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", "license": "ISC", "peer": true }, "node_modules/@mapbox/tiny-sdf": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", "license": "BSD-2-Clause", "peer": true }, "node_modules/@mapbox/unitbezier": { "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", "license": "BSD-2-Clause", "peer": true }, "node_modules/@mapbox/vector-tile": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -6044,6 +6068,8 @@ }, "node_modules/@mapbox/whoots-js": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", "license": "ISC", "peer": true, "engines": { @@ -6052,6 +6078,8 @@ }, "node_modules/@maplibre/maplibre-gl-style-spec": { "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", "license": "ISC", "peer": true, "dependencies": { @@ -6071,11 +6099,15 @@ }, "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", "license": "BSD-2-Clause", "peer": true }, "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC", "peer": true }, @@ -9369,11 +9401,15 @@ }, "node_modules/@plotly/d3": { "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.2.tgz", + "integrity": "sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA==", "license": "BSD-3-Clause", "peer": true }, "node_modules/@plotly/d3-sankey": { "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.7.2.tgz", + "integrity": "sha512-2jdVos1N3mMp3QW0k2q1ph7Gd6j5PY1YihBrwpkFnKqO+cqtZq3AdEYUeSGXMeLsBDQYiqTVcihYfk8vr5tqhw==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -9384,6 +9420,8 @@ }, "node_modules/@plotly/d3-sankey-circular": { "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey-circular/-/d3-sankey-circular-0.33.1.tgz", + "integrity": "sha512-FgBV1HEvCr3DV7RHhDsPXyryknucxtfnLwPtCKKxdolKyTFYoLX/ibEfX39iFYIL7DYbVeRtP43dbFcrHNE+KQ==", "license": "MIT", "peer": true, "dependencies": { @@ -9395,16 +9433,22 @@ }, "node_modules/@plotly/d3-sankey-circular/node_modules/d3-array": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", "license": "BSD-3-Clause", "peer": true }, "node_modules/@plotly/d3-sankey-circular/node_modules/d3-path": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", "license": "BSD-3-Clause", "peer": true }, "node_modules/@plotly/d3-sankey-circular/node_modules/d3-shape": { "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -9413,16 +9457,22 @@ }, "node_modules/@plotly/d3-sankey/node_modules/d3-array": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", "license": "BSD-3-Clause", "peer": true }, "node_modules/@plotly/d3-sankey/node_modules/d3-path": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", "license": "BSD-3-Clause", "peer": true }, "node_modules/@plotly/d3-sankey/node_modules/d3-shape": { "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -9431,6 +9481,9 @@ }, "node_modules/@plotly/mapbox-gl": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", + "integrity": "sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==", + "deprecated": "This package is deprecated as of August 2026. plotly.js v4 uses MapLibre for map traces — see https://github.com/maplibre/maplibre-gl-js.", "license": "SEE LICENSE IN LICENSE.txt", "peer": true, "dependencies": { @@ -9463,6 +9516,8 @@ }, "node_modules/@plotly/point-cluster": { "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@plotly/point-cluster/-/point-cluster-3.1.9.tgz", + "integrity": "sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==", "license": "MIT", "peer": true, "dependencies": { @@ -9480,6 +9535,8 @@ }, "node_modules/@plotly/point-cluster/node_modules/is-obj": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", "license": "MIT", "peer": true, "engines": { @@ -9488,6 +9545,8 @@ }, "node_modules/@plotly/regl": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@plotly/regl/-/regl-2.1.2.tgz", + "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", "license": "MIT", "peer": true }, @@ -11511,7 +11570,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.4.0", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", "peer": true, @@ -11520,9 +11581,9 @@ "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", - "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" }, "engines": { @@ -11531,6 +11592,8 @@ }, "node_modules/@testing-library/dom/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "peer": true, @@ -11540,6 +11603,8 @@ }, "node_modules/@testing-library/dom/node_modules/ansi-styles": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "peer": true, @@ -11552,6 +11617,8 @@ }, "node_modules/@testing-library/dom/node_modules/aria-query": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -11561,12 +11628,16 @@ }, "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@testing-library/dom/node_modules/pretty-format": { "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", "peer": true, @@ -11581,6 +11652,8 @@ }, "node_modules/@testing-library/dom/node_modules/react-is": { "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, "license": "MIT", "peer": true @@ -11707,12 +11780,14 @@ } }, "node_modules/@turf/area": { - "version": "7.3.4", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.4.0.tgz", + "integrity": "sha512-B7q5f6QwKIxxbl4/L59lAYA+FF7h2hv6st96PuKue0TGCYGSxAG/lV8LHSPUSOuOrLywN4jytJcKK3vOFBs7Nw==", "license": "MIT", "peer": true, "dependencies": { - "@turf/helpers": "7.3.4", - "@turf/meta": "7.3.4", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -11721,12 +11796,14 @@ } }, "node_modules/@turf/bbox": { - "version": "7.3.4", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.4.0.tgz", + "integrity": "sha512-yOX9lALc2GmjYUIE99+nRj7eZA7eDtx7Ixu/hrgIo+YqHvL1reaPVTC9rQWMHMm0pH0/WHjzk8uOpAHjptrudQ==", "license": "MIT", "peer": true, "dependencies": { - "@turf/helpers": "7.3.4", - "@turf/meta": "7.3.4", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -11735,12 +11812,14 @@ } }, "node_modules/@turf/centroid": { - "version": "7.3.4", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.4.0.tgz", + "integrity": "sha512-WglCFe+TnMqeYa/LcbUp37NgIF0zHTNYmlxwUm40fyyzyLsYeDq4dgFWLTYpvRNqb5oIMV6xsBjSf+vCPtVJ6w==", "license": "MIT", "peer": true, "dependencies": { - "@turf/helpers": "7.3.4", - "@turf/meta": "7.3.4", + "@turf/helpers": "7.4.0", + "@turf/meta": "7.4.0", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -11749,7 +11828,9 @@ } }, "node_modules/@turf/helpers": { - "version": "7.3.4", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.4.0.tgz", + "integrity": "sha512-7PAwLZqOdRzTI5g9bHvUwlloAXPDH/mlajtryk0tw4ZwGMtmXsAyF4QundsAMfy4u48Fyd3AUqMXY0SMxqJGWg==", "license": "MIT", "peer": true, "dependencies": { @@ -11761,11 +11842,13 @@ } }, "node_modules/@turf/meta": { - "version": "7.3.4", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.4.0.tgz", + "integrity": "sha512-3cLUvlEyDuSnMSzrjhaLAEiYR8xhbfWyTVQlrlEw40xL81d4KF4PqUWbjTXKpXZStdYbet2GCurl79KMypNw6g==", "license": "MIT", "peer": true, "dependencies": { - "@turf/helpers": "7.3.4", + "@turf/helpers": "7.4.0", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -11783,6 +11866,8 @@ }, "node_modules/@types/aria-query": { "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT", "peer": true @@ -12091,24 +12176,6 @@ "dompurify": "*" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -12151,6 +12218,8 @@ }, "node_modules/@types/geojson-vt": { "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", "license": "MIT", "peer": true, "dependencies": { @@ -12267,11 +12336,15 @@ }, "node_modules/@types/mapbox__point-geometry": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", "license": "MIT", "peer": true }, "node_modules/@types/mapbox__vector-tile": { "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", "license": "MIT", "peer": true, "dependencies": { @@ -12331,6 +12404,8 @@ }, "node_modules/@types/pbf": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", "license": "MIT", "peer": true }, @@ -12413,6 +12488,8 @@ }, "node_modules/@types/supercluster": { "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", "license": "MIT", "peer": true, "dependencies": { @@ -12695,6 +12772,382 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/native": { + "name": "typescript", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@uiw/codemirror-extensions-basic-setup": { "version": "4.21.13", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.21.13.tgz", @@ -13076,6 +13529,8 @@ }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", "peer": true, "dependencies": { @@ -13085,21 +13540,29 @@ }, "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", "peer": true, "dependencies": { @@ -13110,11 +13573,15 @@ }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", "peer": true, "dependencies": { @@ -13126,6 +13593,8 @@ }, "node_modules/@webassemblyjs/ieee754": { "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", "peer": true, "dependencies": { @@ -13134,6 +13603,8 @@ }, "node_modules/@webassemblyjs/leb128": { "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -13142,11 +13613,15 @@ }, "node_modules/@webassemblyjs/utf8": { "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", "peer": true, "dependencies": { @@ -13162,6 +13637,8 @@ }, "node_modules/@webassemblyjs/wasm-gen": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", "peer": true, "dependencies": { @@ -13174,6 +13651,8 @@ }, "node_modules/@webassemblyjs/wasm-opt": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", "peer": true, "dependencies": { @@ -13185,6 +13664,8 @@ }, "node_modules/@webassemblyjs/wasm-parser": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", "peer": true, "dependencies": { @@ -13198,6 +13679,8 @@ }, "node_modules/@webassemblyjs/wast-printer": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", "peer": true, "dependencies": { @@ -13624,11 +14107,15 @@ }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "license": "BSD-3-Clause", "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0", "peer": true }, @@ -13688,6 +14175,8 @@ }, "node_modules/abs-svg-path": { "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", "license": "MIT", "peer": true }, @@ -13710,17 +14199,6 @@ "acorn": "^8" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "license": "MIT", @@ -13876,6 +14354,8 @@ }, "node_modules/any-promise": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "license": "MIT", "peer": true }, @@ -13927,6 +14407,8 @@ }, "node_modules/array-bounds": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-bounds/-/array-bounds-1.0.1.tgz", + "integrity": "sha512-8wdW3ZGk6UjMPJx/glyEt0sLzzwAE1bhToPsO1W2pbpR2gULyxe3BjSiuJFheP50T/GgODVPz2fuMUmIywt8cQ==", "license": "MIT", "peer": true }, @@ -13947,6 +14429,8 @@ }, "node_modules/array-find-index": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", "license": "MIT", "peer": true, "engines": { @@ -13981,6 +14465,8 @@ }, "node_modules/array-normalize": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array-normalize/-/array-normalize-1.1.4.tgz", + "integrity": "sha512-fCp0wKFLjvSPmCn4F5Tiw4M3lpMZoHlCjfcs7nNzuj3vqQQ1/a8cgB9DXcpDSn18c+coLnaW7rqfcYCvKbyJXg==", "license": "MIT", "peer": true, "dependencies": { @@ -13989,11 +14475,8 @@ }, "node_modules/array-range": { "version": "1.0.1", - "license": "MIT", - "peer": true - }, - "node_modules/array-rearrange": { - "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array-range/-/array-range-1.0.1.tgz", + "integrity": "sha512-shdaI1zT3CVNL2hnx9c0JMc0ZogGaxDs5e85akgHWKYa0yVbIyp06Ind3dVkTj/uuFrzaHBOyqFzo+VV6aXgtA==", "license": "MIT", "peer": true }, @@ -14474,6 +14957,8 @@ }, "node_modules/base64-arraybuffer": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "license": "MIT", "peer": true, "engines": { @@ -14596,6 +15081,8 @@ }, "node_modules/binary-extensions": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "license": "MIT", "engines": { "node": ">=8" @@ -14606,6 +15093,8 @@ }, "node_modules/binary-search-bounds": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", "license": "MIT", "peer": true }, @@ -14801,11 +15290,15 @@ }, "node_modules/bit-twiddle": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", + "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==", "license": "MIT", "peer": true }, "node_modules/bitmap-sdf": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", "license": "MIT", "peer": true }, @@ -15231,14 +15724,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas-fit": { - "version": "1.5.0", - "license": "MIT", - "peer": true, - "dependencies": { - "element-size": "^1.1.1" - } - }, "node_modules/ccount": { "version": "2.0.1", "license": "MIT", @@ -15364,6 +15849,8 @@ }, "node_modules/chrome-trace-event": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", "peer": true, "engines": { @@ -15389,6 +15876,8 @@ }, "node_modules/clamp": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==", "license": "MIT", "peer": true }, @@ -15602,15 +16091,19 @@ "license": "MIT" }, "node_modules/color-alpha": { - "version": "1.0.4", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-alpha/-/color-alpha-1.1.3.tgz", + "integrity": "sha512-krPYBO1RSO5LH4AGb/b6z70O1Ip2o0F0+0cVFN5FN99jfQtZFT08rQyg+9oOBNJYAn3SRwJIFC8jUEOKz7PisA==", "license": "MIT", "peer": true, "dependencies": { - "color-parse": "^1.3.8" + "color-parse": "^1.4.1" } }, "node_modules/color-alpha/node_modules/color-parse": { "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", "license": "MIT", "peer": true, "dependencies": { @@ -15629,6 +16122,8 @@ }, "node_modules/color-id": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/color-id/-/color-id-1.1.0.tgz", + "integrity": "sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==", "license": "MIT", "peer": true, "dependencies": { @@ -15641,6 +16136,8 @@ }, "node_modules/color-normalize": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/color-normalize/-/color-normalize-1.5.0.tgz", + "integrity": "sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==", "license": "MIT", "peer": true, "dependencies": { @@ -15651,6 +16148,8 @@ }, "node_modules/color-normalize/node_modules/color-parse": { "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", "license": "MIT", "peer": true, "dependencies": { @@ -15659,6 +16158,8 @@ }, "node_modules/color-normalize/node_modules/color-rgba": { "version": "2.4.0", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.4.0.tgz", + "integrity": "sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q==", "license": "MIT", "peer": true, "dependencies": { @@ -15667,15 +16168,29 @@ } }, "node_modules/color-parse": { - "version": "2.0.0", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-2.1.2.tgz", + "integrity": "sha512-z0f1xKxh4za/RSprsmxNkFcyO2o/4VJ2QxiiRRIjfqAsqOXys+BvpOCINNAKOX9AQ/alpS03Wc0WMtEmtLmHzw==", "license": "MIT", "peer": true, "dependencies": { - "color-name": "^1.0.0" + "color-name": "^2.0.0" + } + }, + "node_modules/color-parse/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.20" } }, "node_modules/color-rgba": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-3.0.0.tgz", + "integrity": "sha512-PPwZYkEY3M2THEHHV6Y95sGUie77S7X8v+h1r6LSAPF3/LL2xJ8duUXSrkic31Nzc4odPwHgUbiX/XuTYzQHQg==", "license": "MIT", "peer": true, "dependencies": { @@ -15685,6 +16200,8 @@ }, "node_modules/color-space": { "version": "2.3.2", + "resolved": "https://registry.npmjs.org/color-space/-/color-space-2.3.2.tgz", + "integrity": "sha512-BcKnbOEsOarCwyoLstcoEztwT0IJxqqQkNwDuA3a65sICvvHL2yoeV13psoDFh5IuiOMnIOKdQDwB4Mk3BypiA==", "license": "Unlicense", "peer": true }, @@ -16063,6 +16580,8 @@ }, "node_modules/country-regex": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/country-regex/-/country-regex-1.1.0.tgz", + "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", "license": "MIT", "peer": true }, @@ -16142,6 +16661,8 @@ }, "node_modules/css-font": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-font/-/css-font-1.2.0.tgz", + "integrity": "sha512-V4U4Wps4dPDACJ4WpgofJ2RT5Yqwe1lEH6wlOOaIxMi0gTjdIijsc5FmxQlZ7ZZyKQkkutqqvULOp07l9c7ssA==", "license": "MIT", "peer": true, "dependencies": { @@ -16158,26 +16679,36 @@ }, "node_modules/css-font-size-keywords": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz", + "integrity": "sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==", "license": "MIT", "peer": true }, "node_modules/css-font-stretch-keywords": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz", + "integrity": "sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==", "license": "MIT", "peer": true }, "node_modules/css-font-style-keywords": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz", + "integrity": "sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==", "license": "MIT", "peer": true }, "node_modules/css-font-weight-keywords": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz", + "integrity": "sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==", "license": "MIT", "peer": true }, "node_modules/css-global-keywords": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-global-keywords/-/css-global-keywords-1.0.1.tgz", + "integrity": "sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==", "license": "MIT", "peer": true }, @@ -16347,6 +16878,8 @@ }, "node_modules/css-system-font-keywords": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", + "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==", "license": "MIT", "peer": true }, @@ -16378,6 +16911,8 @@ }, "node_modules/csscolorparser": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", "license": "MIT", "peer": true }, @@ -16546,6 +17081,8 @@ }, "node_modules/d": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", "license": "ISC", "peer": true, "dependencies": { @@ -16638,6 +17175,8 @@ }, "node_modules/d3-collection": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", "license": "BSD-3-Clause", "peer": true }, @@ -16764,6 +17303,8 @@ }, "node_modules/d3-geo-projection": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-2.9.0.tgz", + "integrity": "sha512-ZULvK/zBn87of5rWAfFMc9mJOipeSo57O+BBitsKIXmU4rTVAnX1kSsJkE0R+TxY8pGNoM1nbyRRE7GYHhdOEQ==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -16782,16 +17323,22 @@ }, "node_modules/d3-geo-projection/node_modules/commander": { "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT", "peer": true }, "node_modules/d3-geo-projection/node_modules/d3-array": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", "license": "BSD-3-Clause", "peer": true }, "node_modules/d3-geo-projection/node_modules/d3-geo": { "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -17032,7 +17579,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT", "peer": true }, @@ -17251,6 +17800,8 @@ }, "node_modules/defined": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", "license": "MIT", "peer": true, "funding": { @@ -17298,6 +17849,8 @@ }, "node_modules/detect-kerning": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", + "integrity": "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==", "license": "MIT", "peer": true }, @@ -17332,6 +17885,8 @@ }, "node_modules/didyoumean": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0", "peer": true }, @@ -17355,6 +17910,8 @@ }, "node_modules/dlv": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT", "peer": true }, @@ -17450,6 +18007,8 @@ }, "node_modules/draw-svg-path": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", + "integrity": "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg==", "license": "MIT", "peer": true, "dependencies": { @@ -17459,6 +18018,8 @@ }, "node_modules/dtype": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz", + "integrity": "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg==", "license": "MIT", "peer": true, "engines": { @@ -17479,11 +18040,15 @@ }, "node_modules/dup": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dup/-/dup-1.0.0.tgz", + "integrity": "sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==", "license": "MIT", "peer": true }, "node_modules/duplexify": { "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", "license": "MIT", "peer": true, "dependencies": { @@ -17495,11 +18060,15 @@ }, "node_modules/duplexify/node_modules/isarray": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT", "peer": true }, "node_modules/duplexify/node_modules/readable-stream": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "peer": true, "dependencies": { @@ -17514,11 +18083,15 @@ }, "node_modules/duplexify/node_modules/safe-buffer": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT", "peer": true }, "node_modules/duplexify/node_modules/string_decoder": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "peer": true, "dependencies": { @@ -17535,6 +18108,8 @@ }, "node_modules/earcut": { "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", "license": "ISC", "peer": true }, @@ -17585,19 +18160,22 @@ "version": "1.5.302", "license": "ISC" }, - "node_modules/element-size": { - "version": "1.1.1", - "license": "MIT", - "peer": true - }, "node_modules/elementary-circuits-directed-graph": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/elementary-circuits-directed-graph/-/elementary-circuits-directed-graph-1.3.1.tgz", + "integrity": "sha512-ZEiB5qkn2adYmpXGnJKkxT8uJHlW/mxmBpmeqawEHzPxh9HkLD4/1mFYX5l0On+f6rcPIt8/EWlRU2Vo3fX6dQ==", "license": "MIT", "peer": true, "dependencies": { "strongly-connected-components": "^1.0.1" } }, + "node_modules/elkjs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.12.0.tgz", + "integrity": "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==", + "license": "EPL-2.0 OR GPL-3.0-or-later" + }, "node_modules/emittery": { "version": "0.13.1", "license": "MIT", @@ -17637,12 +18215,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.19.0", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "license": "MIT", "peer": true, "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -17806,6 +18386,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "license": "MIT", + "peer": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -17867,6 +18454,8 @@ }, "node_modules/es5-ext": { "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", "hasInstallScript": true, "license": "ISC", "peer": true, @@ -17882,6 +18471,8 @@ }, "node_modules/es6-iterator": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", "license": "MIT", "peer": true, "dependencies": { @@ -17892,6 +18483,8 @@ }, "node_modules/es6-symbol": { "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", "license": "ISC", "peer": true, "dependencies": { @@ -17904,6 +18497,8 @@ }, "node_modules/es6-weak-map": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", "license": "ISC", "peer": true, "dependencies": { @@ -18007,6 +18602,8 @@ }, "node_modules/escodegen": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -18027,6 +18624,8 @@ }, "node_modules/escodegen/node_modules/source-map": { "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "optional": true, "peer": true, @@ -18514,6 +19113,8 @@ }, "node_modules/esniff": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", "license": "ISC", "peer": true, "dependencies": { @@ -18683,6 +19284,8 @@ }, "node_modules/event-emitter": { "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", "license": "MIT", "peer": true, "dependencies": { @@ -18697,6 +19300,8 @@ }, "node_modules/events": { "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", "peer": true, "engines": { @@ -18765,6 +19370,8 @@ }, "node_modules/ext": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", "license": "ISC", "peer": true, "dependencies": { @@ -18822,6 +19429,8 @@ }, "node_modules/falafel": { "version": "2.2.5", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz", + "integrity": "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==", "license": "MIT", "peer": true, "dependencies": { @@ -18834,6 +19443,8 @@ }, "node_modules/falafel/node_modules/acorn": { "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "license": "MIT", "peer": true, "bin": { @@ -18887,6 +19498,8 @@ }, "node_modules/fast-isnumeric": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", + "integrity": "sha512-1mM8qOr2LYz8zGaUdmiqRDiuue00Dxjgcb1NQR7TnhLVh6sQyngP9xvLo7Sl7LZpP/sk5eb+bcyWXw530NTBZw==", "license": "MIT", "peer": true, "dependencies": { @@ -19144,6 +19757,8 @@ }, "node_modules/flatten-vertex-data": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flatten-vertex-data/-/flatten-vertex-data-1.0.2.tgz", + "integrity": "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==", "license": "MIT", "peer": true, "dependencies": { @@ -19172,6 +19787,8 @@ }, "node_modules/font-atlas": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", + "integrity": "sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==", "license": "MIT", "peer": true, "dependencies": { @@ -19180,6 +19797,8 @@ }, "node_modules/font-measure": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/font-measure/-/font-measure-1.2.2.tgz", + "integrity": "sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA==", "license": "MIT", "peer": true, "dependencies": { @@ -19261,6 +19880,8 @@ }, "node_modules/from2": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", "license": "MIT", "peer": true, "dependencies": { @@ -19270,11 +19891,15 @@ }, "node_modules/from2/node_modules/isarray": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT", "peer": true }, "node_modules/from2/node_modules/readable-stream": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "peer": true, "dependencies": { @@ -19289,11 +19914,15 @@ }, "node_modules/from2/node_modules/safe-buffer": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT", "peer": true }, "node_modules/from2/node_modules/string_decoder": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "peer": true, "dependencies": { @@ -19426,6 +20055,8 @@ }, "node_modules/geojson-vt": { "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", "license": "ISC", "peer": true }, @@ -19438,6 +20069,8 @@ }, "node_modules/get-canvas-context": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-canvas-context/-/get-canvas-context-1.0.2.tgz", + "integrity": "sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==", "license": "MIT", "peer": true }, @@ -19775,16 +20408,22 @@ }, "node_modules/gl-mat4": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", + "integrity": "sha512-sT5C0pwB1/e9G9AvAoLsoaJtbMGjfd/jfxo8jMCKqYYEnjZuFvqV5rehqar0538EmssjdDeiEWnKyBSTw7quoA==", "license": "Zlib", "peer": true }, "node_modules/gl-matrix": { "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", "license": "MIT", "peer": true }, "node_modules/gl-text": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gl-text/-/gl-text-1.4.0.tgz", + "integrity": "sha512-o47+XBqLCj1efmuNyCHt7/UEJmB9l66ql7pnobD6p+sgmBUdzfMZXIF0zD2+KRfpd99DJN+QXdvTFAGCKCVSmQ==", "license": "MIT", "peer": true, "dependencies": { @@ -19809,6 +20448,8 @@ }, "node_modules/gl-util": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/gl-util/-/gl-util-3.1.3.tgz", + "integrity": "sha512-dvRTggw5MSkJnCbh74jZzSoTOGnVYK+Bt+Ckqm39CVcl6+zSsxqWk4lr5NKhkqXHL6qvZAU9h17ZF8mIskY9mA==", "license": "MIT", "peer": true, "dependencies": { @@ -19849,10 +20490,56 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "license": "BSD-2-Clause", - "peer": true + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "peer": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } }, "node_modules/globals": { "version": "17.3.0", @@ -19916,6 +20603,8 @@ }, "node_modules/glsl-inject-defines": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/glsl-inject-defines/-/glsl-inject-defines-1.0.3.tgz", + "integrity": "sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==", "license": "MIT", "peer": true, "dependencies": { @@ -19926,6 +20615,8 @@ }, "node_modules/glsl-resolve": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/glsl-resolve/-/glsl-resolve-0.0.1.tgz", + "integrity": "sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==", "license": "MIT", "peer": true, "dependencies": { @@ -19935,11 +20626,15 @@ }, "node_modules/glsl-resolve/node_modules/resolve": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", + "integrity": "sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==", "license": "MIT", "peer": true }, "node_modules/glsl-resolve/node_modules/xtend": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", + "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", "peer": true, "engines": { "node": ">=0.4" @@ -19947,11 +20642,15 @@ }, "node_modules/glsl-token-assignments": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-assignments/-/glsl-token-assignments-2.0.2.tgz", + "integrity": "sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==", "license": "MIT", "peer": true }, "node_modules/glsl-token-defines": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-defines/-/glsl-token-defines-1.0.0.tgz", + "integrity": "sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==", "license": "MIT", "peer": true, "dependencies": { @@ -19960,11 +20659,15 @@ }, "node_modules/glsl-token-depth": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-depth/-/glsl-token-depth-1.1.2.tgz", + "integrity": "sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==", "license": "MIT", "peer": true }, "node_modules/glsl-token-descope": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-descope/-/glsl-token-descope-1.0.2.tgz", + "integrity": "sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==", "license": "MIT", "peer": true, "dependencies": { @@ -19976,31 +20679,43 @@ }, "node_modules/glsl-token-inject-block": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/glsl-token-inject-block/-/glsl-token-inject-block-1.1.0.tgz", + "integrity": "sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==", "license": "MIT", "peer": true }, "node_modules/glsl-token-properties": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-properties/-/glsl-token-properties-1.0.1.tgz", + "integrity": "sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==", "license": "MIT", "peer": true }, "node_modules/glsl-token-scope": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-scope/-/glsl-token-scope-1.1.2.tgz", + "integrity": "sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==", "license": "MIT", "peer": true }, "node_modules/glsl-token-string": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-string/-/glsl-token-string-1.0.1.tgz", + "integrity": "sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==", "license": "MIT", "peer": true }, "node_modules/glsl-token-whitespace-trim": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-whitespace-trim/-/glsl-token-whitespace-trim-1.0.0.tgz", + "integrity": "sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==", "license": "MIT", "peer": true }, "node_modules/glsl-tokenizer": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", + "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", "license": "MIT", "peer": true, "dependencies": { @@ -20009,11 +20724,15 @@ }, "node_modules/glsl-tokenizer/node_modules/isarray": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "license": "MIT", "peer": true }, "node_modules/glsl-tokenizer/node_modules/readable-stream": { "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", "license": "MIT", "peer": true, "dependencies": { @@ -20025,11 +20744,15 @@ }, "node_modules/glsl-tokenizer/node_modules/string_decoder": { "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "license": "MIT", "peer": true }, "node_modules/glsl-tokenizer/node_modules/through2": { "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", "license": "MIT", "peer": true, "dependencies": { @@ -20039,6 +20762,8 @@ }, "node_modules/glslify": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glslify/-/glslify-7.1.1.tgz", + "integrity": "sha512-bud98CJ6kGZcP9Yxcsi7Iz647wuDz3oN+IZsjCRi5X1PI7t/xPKeL0mOwXJjo+CRZMqvq0CkSJiywCcY7kVYog==", "license": "MIT", "peer": true, "dependencies": { @@ -20064,6 +20789,8 @@ }, "node_modules/glslify-bundle": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glslify-bundle/-/glslify-bundle-5.1.1.tgz", + "integrity": "sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==", "license": "MIT", "peer": true, "dependencies": { @@ -20081,6 +20808,8 @@ }, "node_modules/glslify-deps": { "version": "1.3.2", + "resolved": "https://registry.npmjs.org/glslify-deps/-/glslify-deps-1.3.2.tgz", + "integrity": "sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==", "license": "ISC", "peer": true, "dependencies": { @@ -20096,6 +20825,8 @@ }, "node_modules/glslify/node_modules/bl": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", "license": "MIT", "peer": true, "dependencies": { @@ -20105,6 +20836,8 @@ }, "node_modules/glslify/node_modules/concat-stream": { "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "engines": [ "node >= 0.8" ], @@ -20119,11 +20852,15 @@ }, "node_modules/glslify/node_modules/isarray": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT", "peer": true }, "node_modules/glslify/node_modules/readable-stream": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "peer": true, "dependencies": { @@ -20138,11 +20875,15 @@ }, "node_modules/glslify/node_modules/safe-buffer": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT", "peer": true }, "node_modules/glslify/node_modules/string_decoder": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "peer": true, "dependencies": { @@ -20279,6 +21020,8 @@ }, "node_modules/grid-index": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", "license": "ISC", "peer": true }, @@ -20340,6 +21083,8 @@ }, "node_modules/has-hover": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-hover/-/has-hover-1.0.1.tgz", + "integrity": "sha512-0G6w7LnlcpyDzpeGUTuT0CEw05+QlMuGVk1IHNAlHrGJITGodjZu3x8BNDUMfKJSZXNB2ZAclqc1bvrd+uUpfg==", "license": "MIT", "peer": true, "dependencies": { @@ -20348,6 +21093,8 @@ }, "node_modules/has-passive-events": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-passive-events/-/has-passive-events-1.0.0.tgz", + "integrity": "sha512-2vSj6IeIsgvsRMyeQ0JaCX5Q3lX4zMn5HpoVc7MEhQ6pv8Iq9rsXjsp+E5ZwaT7T0xhMT0KmU8gtt1EFVdbJiw==", "license": "MIT", "peer": true, "dependencies": { @@ -21027,6 +21774,8 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -21052,6 +21801,8 @@ }, "node_modules/is-browser": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", + "integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==", "license": "MIT", "peer": true }, @@ -21199,6 +21950,8 @@ }, "node_modules/is-finite": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", "license": "MIT", "peer": true, "engines": { @@ -21210,6 +21963,8 @@ }, "node_modules/is-firefox": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-firefox/-/is-firefox-1.0.3.tgz", + "integrity": "sha512-6Q9ITjvWIm0Xdqv+5U12wgOKEM2KoBw4Y926m0OFkvlCxnbG94HKAsVz8w3fWcfAS5YA2fJORXX1dLrkprCCxA==", "license": "MIT", "peer": true, "engines": { @@ -21266,14 +22021,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-iexplorer": { - "version": "1.0.0", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -21330,6 +22077,8 @@ }, "node_modules/is-mobile": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-4.0.0.tgz", + "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==", "license": "MIT", "peer": true }, @@ -21477,11 +22226,15 @@ }, "node_modules/is-string-blank": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", + "integrity": "sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==", "license": "MIT", "peer": true }, "node_modules/is-svg-path": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-svg-path/-/is-svg-path-1.0.2.tgz", + "integrity": "sha512-Lj4vePmqpPR1ZnRctHv8ltSh1OrSxHkhUkd7wi+VQdcdP15/KvQFyk7LhNuM7ZW0EVbJz8kZLVmL9quLrfq4Kg==", "license": "MIT", "peer": true }, @@ -22708,6 +23461,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "peer": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/jose": { "version": "6.1.3", "license": "MIT", @@ -22857,6 +23620,8 @@ }, "node_modules/json-stringify-pretty-compact": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", "license": "MIT", "peer": true }, @@ -22995,7 +23760,9 @@ "license": "MIT" }, "node_modules/kdbush": { - "version": "4.0.2", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", "license": "ISC", "peer": true }, @@ -23679,18 +24446,6 @@ "node": ">=8" } }, - "node_modules/loader-runner": { - "version": "4.3.1", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/loader-utils": { "version": "2.0.4", "license": "MIT", @@ -23828,6 +24583,8 @@ }, "node_modules/lz-string": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", "peer": true, @@ -23957,6 +24714,8 @@ }, "node_modules/map-limit": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", + "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", "license": "MIT", "peer": true, "dependencies": { @@ -23965,6 +24724,8 @@ }, "node_modules/map-limit/node_modules/once": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", "license": "ISC", "peer": true, "dependencies": { @@ -23986,6 +24747,8 @@ }, "node_modules/mapbox-gl": { "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", "license": "SEE LICENSE IN LICENSE.txt", "peer": true, "dependencies": { @@ -24018,6 +24781,8 @@ }, "node_modules/maplibre-gl": { "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -24057,66 +24822,51 @@ } }, "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { - "version": "2.0.7", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", "license": "BSD-2-Clause", "peer": true }, "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", "license": "BSD-2-Clause", "peer": true }, "node_modules/maplibre-gl/node_modules/earcut": { - "version": "3.0.2", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", "license": "ISC", "peer": true }, "node_modules/maplibre-gl/node_modules/geojson-vt": { - "version": "4.0.2", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz", + "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==", "license": "ISC", "peer": true }, - "node_modules/maplibre-gl/node_modules/global-prefix": { - "version": "4.0.0", - "license": "MIT", - "peer": true, - "dependencies": { - "ini": "^4.1.3", - "kind-of": "^6.0.3", - "which": "^4.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/maplibre-gl/node_modules/ini": { - "version": "4.1.3", - "license": "ISC", - "peer": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/maplibre-gl/node_modules/isexe": { - "version": "3.1.5", - "license": "BlueOak-1.0.0", - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/maplibre-gl/node_modules/potpack": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", "license": "ISC", "peer": true }, "node_modules/maplibre-gl/node_modules/quickselect": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC", "peer": true }, "node_modules/maplibre-gl/node_modules/supercluster": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", "license": "ISC", "peer": true, "dependencies": { @@ -24125,23 +24875,11 @@ }, "node_modules/maplibre-gl/node_modules/tinyqueue": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC", "peer": true }, - "node_modules/maplibre-gl/node_modules/which": { - "version": "4.0.0", - "license": "ISC", - "peer": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, "node_modules/markdown-extensions": { "version": "2.0.0", "license": "MIT", @@ -24169,6 +24907,8 @@ }, "node_modules/math-log2": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz", + "integrity": "sha512-9W0yGtkaMAkf74XGYVy4Dqw3YUMnTNB2eeiw9aQbUl4A3KmuCEHTt2DgAB07ENzOYAjsYSAYufkAq0Zd+jU7zA==", "license": "MIT", "peer": true, "engines": { @@ -25341,7 +26081,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -25460,6 +26199,155 @@ "node": ">= 6" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/minipass": { "version": "7.1.3", "license": "BlueOak-1.0.0", @@ -25649,34 +26537,13 @@ "color-name": "^1.1.4" } }, - "node_modules/mouse-change": { - "version": "1.4.0", - "license": "MIT", - "peer": true, - "dependencies": { - "mouse-event": "^1.0.0" - } - }, - "node_modules/mouse-event": { - "version": "1.0.5", - "license": "MIT", - "peer": true - }, "node_modules/mouse-event-offset": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mouse-event-offset/-/mouse-event-offset-3.0.2.tgz", + "integrity": "sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==", "license": "MIT", "peer": true }, - "node_modules/mouse-wheel": { - "version": "1.2.0", - "license": "MIT", - "peer": true, - "dependencies": { - "right-now": "^1.0.0", - "signum": "^1.0.0", - "to-px": "^1.0.1" - } - }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -25751,6 +26618,8 @@ }, "node_modules/murmurhash-js": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", "license": "MIT", "peer": true }, @@ -25772,6 +26641,8 @@ }, "node_modules/mz": { "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "license": "MIT", "peer": true, "dependencies": { @@ -25813,6 +26684,8 @@ }, "node_modules/native-promise-only": { "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", "license": "MIT", "peer": true }, @@ -25822,6 +26695,8 @@ }, "node_modules/needle": { "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", "license": "MIT", "peer": true, "dependencies": { @@ -25838,6 +26713,8 @@ }, "node_modules/needle/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", "peer": true, "dependencies": { @@ -25846,6 +26723,8 @@ }, "node_modules/needle/node_modules/iconv-lite": { "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "peer": true, "dependencies": { @@ -25944,6 +26823,8 @@ }, "node_modules/next-tick": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "license": "ISC", "peer": true }, @@ -26205,6 +27086,8 @@ }, "node_modules/normalize-svg-path": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz", + "integrity": "sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==", "license": "MIT", "peer": true }, @@ -26378,6 +27261,8 @@ }, "node_modules/number-is-integer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-integer/-/number-is-integer-1.0.1.tgz", + "integrity": "sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==", "license": "MIT", "peer": true, "dependencies": { @@ -26756,6 +27641,8 @@ }, "node_modules/object-hash": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", "peer": true, "engines": { @@ -27438,6 +28325,8 @@ }, "node_modules/parenthesis": { "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", "license": "MIT", "peer": true }, @@ -27536,6 +28425,8 @@ }, "node_modules/parse-rect": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parse-rect/-/parse-rect-1.2.0.tgz", + "integrity": "sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA==", "license": "MIT", "peer": true, "dependencies": { @@ -27544,11 +28435,15 @@ }, "node_modules/parse-svg-path": { "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", "license": "MIT", "peer": true }, "node_modules/parse-unit": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz", + "integrity": "sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==", "license": "MIT", "peer": true }, @@ -27674,6 +28569,8 @@ }, "node_modules/pbf": { "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -27693,11 +28590,15 @@ }, "node_modules/performance-now": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "license": "MIT", "peer": true }, "node_modules/pick-by-alias": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pick-by-alias/-/pick-by-alias-1.2.0.tgz", + "integrity": "sha512-ESj2+eBxhGrcA1azgHs7lARG5+5iLakc/6nlfbpjcLl00HuuUOIuORhYXN4D1HfvMSKuVtFQjAlnwi1JHEeDIw==", "license": "MIT", "peer": true }, @@ -27806,51 +28707,10 @@ "pathe": "^2.0.1" } }, - "node_modules/playwright": { - "version": "1.58.2", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/plotly.js": { - "version": "3.4.0", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-3.7.0.tgz", + "integrity": "sha512-r17/w3Ob/E+A1vqN382PV/6Dz+IAxAsxNCpWcnGfGFLqE0M6vPUa+ve7HPhivrl9LMicZg2Fy0U5GbpauHo32A==", "license": "MIT", "peer": true, "dependencies": { @@ -27863,10 +28723,7 @@ "@turf/bbox": "^7.1.0", "@turf/centroid": "^7.1.0", "base64-arraybuffer": "^1.0.2", - "canvas-fit": "^1.5.0", - "color-alpha": "1.0.4", "color-normalize": "1.5.0", - "color-parse": "2.0.0", "color-rgba": "3.0.0", "country-regex": "^1.1.0", "d3-force": "^1.2.1", @@ -27884,9 +28741,7 @@ "has-passive-events": "^1.0.0", "is-mobile": "^4.0.0", "maplibre-gl": "^4.7.1", - "mouse-change": "^1.4.0", "mouse-event-offset": "^3.0.2", - "mouse-wheel": "^1.2.0", "native-promise-only": "^0.8.1", "parse-svg-path": "^0.1.2", "point-in-polygon": "^1.1.0", @@ -27897,10 +28752,8 @@ "regl-scatter2d": "^3.3.1", "regl-splom": "^1.0.14", "strongly-connected-components": "^1.0.1", - "superscript-text": "^1.0.0", "svg-path-sdf": "^1.1.3", "tinycolor2": "^1.4.2", - "to-px": "1.0.1", "topojson-client": "^3.1.0", "webgl-context": "^2.2.0", "world-calendars": "^1.0.4" @@ -27911,16 +28764,22 @@ }, "node_modules/plotly.js/node_modules/d3-array": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", "license": "BSD-3-Clause", "peer": true }, "node_modules/plotly.js/node_modules/d3-dispatch": { "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", "license": "BSD-3-Clause", "peer": true }, "node_modules/plotly.js/node_modules/d3-force": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -27932,11 +28791,15 @@ }, "node_modules/plotly.js/node_modules/d3-format": { "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", "license": "BSD-3-Clause", "peer": true }, "node_modules/plotly.js/node_modules/d3-geo": { "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -27945,21 +28808,29 @@ }, "node_modules/plotly.js/node_modules/d3-hierarchy": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", "license": "BSD-3-Clause", "peer": true }, "node_modules/plotly.js/node_modules/d3-quadtree": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", "license": "BSD-3-Clause", "peer": true }, "node_modules/plotly.js/node_modules/d3-time": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", "license": "BSD-3-Clause", "peer": true }, "node_modules/plotly.js/node_modules/d3-time-format": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -27968,6 +28839,8 @@ }, "node_modules/plotly.js/node_modules/d3-timer": { "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", "license": "BSD-3-Clause", "peer": true }, @@ -27980,11 +28853,15 @@ }, "node_modules/point-in-polygon": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", "license": "MIT", "peer": true }, "node_modules/polybooljs": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/polybooljs/-/polybooljs-1.2.2.tgz", + "integrity": "sha512-ziHW/02J0XuNuUtmidBc6GXE8YohYydp3DWPWXYsd7O721TjcmN+k6ezjdwkDqep+gnWnFY+yqZHvzElra2oCg==", "license": "MIT", "peer": true }, @@ -29538,6 +30415,8 @@ }, "node_modules/potpack": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", "license": "ISC", "peer": true }, @@ -29626,7 +30505,19 @@ } }, "node_modules/probe-image-size": { - "version": "7.2.3", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.3.0.tgz", + "integrity": "sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "peer": true, "dependencies": { @@ -29801,11 +30692,15 @@ }, "node_modules/quickselect": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", "license": "ISC", "peer": true }, "node_modules/raf": { "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", "license": "MIT", "peer": true, "dependencies": { @@ -30604,11 +31499,15 @@ }, "node_modules/regl": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz", + "integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==", "license": "MIT", "peer": true }, "node_modules/regl-error2d": { "version": "2.0.12", + "resolved": "https://registry.npmjs.org/regl-error2d/-/regl-error2d-2.0.12.tgz", + "integrity": "sha512-r7BUprZoPO9AbyqM5qlJesrSRkl+hZnVKWKsVp7YhOl/3RIpi4UDGASGJY0puQ96u5fBYw/OlqV24IGcgJ0McA==", "license": "MIT", "peer": true, "dependencies": { @@ -30623,6 +31522,8 @@ }, "node_modules/regl-line2d": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/regl-line2d/-/regl-line2d-3.1.3.tgz", + "integrity": "sha512-fkgzW+tTn4QUQLpFKsUIE0sgWdCmXAM3ctXcCgoGBZTSX5FE2A0M7aynz7nrZT5baaftLrk9te54B+MEq4QcSA==", "license": "MIT", "peer": true, "dependencies": { @@ -30640,46 +31541,28 @@ } }, "node_modules/regl-scatter2d": { - "version": "3.3.1", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/regl-scatter2d/-/regl-scatter2d-3.4.0.tgz", + "integrity": "sha512-DavKQlHsI+iHZuLgOL+yGkg+sPd94CS+7FCBWkcQ6s/TbaNfUsF9eN591fjjSWIoKrGNfb/SEGhsXR5lXjqZ2w==", "license": "MIT", "peer": true, "dependencies": { "@plotly/point-cluster": "^3.1.9", - "array-range": "^1.0.1", - "array-rearrange": "^2.2.2", - "clamp": "^1.0.1", + "array-bounds": "^1.0.1", "color-id": "^1.1.0", "color-normalize": "^1.5.0", - "color-rgba": "^2.1.1", "flatten-vertex-data": "^1.0.2", "glslify": "^7.0.0", - "is-iexplorer": "^1.0.0", - "object-assign": "^4.1.1", "parse-rect": "^1.2.0", "pick-by-alias": "^1.2.0", "to-float32": "^1.1.0", "update-diff": "^1.1.0" } }, - "node_modules/regl-scatter2d/node_modules/color-parse": { - "version": "1.4.3", - "license": "MIT", - "peer": true, - "dependencies": { - "color-name": "^1.0.0" - } - }, - "node_modules/regl-scatter2d/node_modules/color-rgba": { - "version": "2.4.0", - "license": "MIT", - "peer": true, - "dependencies": { - "color-parse": "^1.4.2", - "color-space": "^2.0.0" - } - }, "node_modules/regl-splom": { "version": "1.0.14", + "resolved": "https://registry.npmjs.org/regl-splom/-/regl-splom-1.0.14.tgz", + "integrity": "sha512-OiLqjmPRYbd7kDlHC6/zDf6L8lxgDC65BhC8JirhP4ykrK4x22ZyS+BnY8EUinXKDeMgmpRwCvUmk7BK4Nweuw==", "license": "MIT", "peer": true, "dependencies": { @@ -30984,6 +31867,8 @@ }, "node_modules/resolve-protobuf-schema": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", "license": "MIT", "peer": true, "dependencies": { @@ -31047,11 +31932,6 @@ "node": ">=0.10.0" } }, - "node_modules/right-now": { - "version": "1.0.0", - "license": "MIT", - "peer": true - }, "node_modules/robust-predicates": { "version": "3.0.2", "license": "Unlicense" @@ -31719,6 +32599,8 @@ }, "node_modules/shallow-copy": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==", "license": "MIT", "peer": true }, @@ -31853,11 +32735,6 @@ "version": "3.0.7", "license": "ISC" }, - "node_modules/signum": { - "version": "1.0.0", - "license": "MIT", - "peer": true - }, "node_modules/sigstore": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", @@ -32108,6 +32985,8 @@ }, "node_modules/stack-trace": { "version": "0.0.9", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz", + "integrity": "sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==", "peer": true, "engines": { "node": "*" @@ -32132,6 +33011,8 @@ }, "node_modules/static-eval": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", "license": "MIT", "peer": true, "dependencies": { @@ -32247,6 +33128,8 @@ }, "node_modules/stream-parser": { "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", "license": "MIT", "peer": true, "dependencies": { @@ -32255,6 +33138,8 @@ }, "node_modules/stream-parser/node_modules/debug": { "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "peer": true, "dependencies": { @@ -32263,11 +33148,15 @@ }, "node_modules/stream-parser/node_modules/ms": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT", "peer": true }, "node_modules/stream-shift": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT", "peer": true }, @@ -32331,6 +33220,8 @@ }, "node_modules/string-split-by": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", "license": "MIT", "peer": true, "dependencies": { @@ -32601,6 +33492,8 @@ }, "node_modules/strongly-connected-components": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", + "integrity": "sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==", "license": "MIT", "peer": true }, @@ -32699,6 +33592,8 @@ }, "node_modules/sucrase": { "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "license": "MIT", "peer": true, "dependencies": { @@ -32720,6 +33615,8 @@ }, "node_modules/sucrase/node_modules/commander": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", "license": "MIT", "peer": true, "engines": { @@ -32728,6 +33625,8 @@ }, "node_modules/sucrase/node_modules/lines-and-columns": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT", "peer": true }, @@ -32772,6 +33671,8 @@ }, "node_modules/supercluster": { "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", "license": "ISC", "peer": true, "dependencies": { @@ -32780,14 +33681,11 @@ }, "node_modules/supercluster/node_modules/kdbush": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", "license": "ISC", "peer": true }, - "node_modules/superscript-text": { - "version": "1.0.0", - "license": "MIT", - "peer": true - }, "node_modules/supports-color": { "version": "7.2.0", "license": "MIT", @@ -32810,6 +33708,8 @@ }, "node_modules/svg-arc-to-cubic-bezier": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", "license": "ISC", "peer": true }, @@ -32820,6 +33720,8 @@ }, "node_modules/svg-path-bounds": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/svg-path-bounds/-/svg-path-bounds-1.0.2.tgz", + "integrity": "sha512-H4/uAgLWrppIC0kHsb2/dWUYSmb4GE5UqH06uqWBcg6LBjX2fu0A8+JrO2/FJPZiSsNOKZAhyFFgsLTdYUvSqQ==", "license": "MIT", "peer": true, "dependencies": { @@ -32831,6 +33733,8 @@ }, "node_modules/svg-path-bounds/node_modules/normalize-svg-path": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", "license": "MIT", "peer": true, "dependencies": { @@ -32839,6 +33743,8 @@ }, "node_modules/svg-path-sdf": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/svg-path-sdf/-/svg-path-sdf-1.1.3.tgz", + "integrity": "sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==", "license": "MIT", "peer": true, "dependencies": { @@ -33051,6 +33957,8 @@ }, "node_modules/tailwindcss": { "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", "peer": true, "dependencies": { @@ -33087,11 +33995,15 @@ }, "node_modules/tailwindcss/node_modules/arg": { "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT", "peer": true }, "node_modules/tailwindcss/node_modules/chokidar": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", "peer": true, "dependencies": { @@ -33115,6 +34027,8 @@ }, "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "license": "ISC", "peer": true, "dependencies": { @@ -33126,6 +34040,8 @@ }, "node_modules/tailwindcss/node_modules/fast-glob": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "license": "MIT", "peer": true, "dependencies": { @@ -33141,6 +34057,8 @@ }, "node_modules/tailwindcss/node_modules/fast-glob/node_modules/glob-parent": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "license": "ISC", "peer": true, "dependencies": { @@ -33150,16 +34068,10 @@ "node": ">= 6" } }, - "node_modules/tailwindcss/node_modules/jiti": { - "version": "1.21.7", - "license": "MIT", - "peer": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/tailwindcss/node_modules/lilconfig": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "license": "MIT", "peer": true, "engines": { @@ -33184,6 +34096,8 @@ }, "node_modules/tailwindcss/node_modules/postcss-import": { "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", "license": "MIT", "peer": true, "dependencies": { @@ -33200,6 +34114,8 @@ }, "node_modules/tailwindcss/node_modules/postcss-load-config": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "funding": [ { "type": "opencollective", @@ -33241,6 +34157,8 @@ }, "node_modules/tailwindcss/node_modules/postcss-nested": { "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "funding": [ { "type": "opencollective", @@ -33264,7 +34182,9 @@ } }, "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.2", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "peer": true, "dependencies": { @@ -33277,6 +34197,8 @@ }, "node_modules/tailwindcss/node_modules/readdirp": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "license": "MIT", "peer": true, "dependencies": { @@ -33287,7 +34209,9 @@ } }, "node_modules/tapable": { - "version": "2.3.0", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", "engines": { "node": ">=6" @@ -33356,6 +34280,7 @@ "version": "5.6.1", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", @@ -33414,6 +34339,7 @@ }, "node_modules/terser-webpack-plugin/node_modules/ajv": { "version": "8.18.0", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -33428,6 +34354,7 @@ }, "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { "version": "5.1.0", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" @@ -33438,6 +34365,7 @@ }, "node_modules/terser-webpack-plugin/node_modules/jest-worker": { "version": "27.5.1", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -33450,10 +34378,12 @@ }, "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { "version": "1.0.0", + "dev": true, "license": "MIT" }, "node_modules/terser-webpack-plugin/node_modules/schema-utils": { "version": "4.3.3", + "dev": true, "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", @@ -33471,6 +34401,7 @@ }, "node_modules/terser-webpack-plugin/node_modules/supports-color": { "version": "8.1.1", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -33568,6 +34499,8 @@ }, "node_modules/thenify": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "license": "MIT", "peer": true, "dependencies": { @@ -33576,6 +34509,8 @@ }, "node_modules/thenify-all": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "license": "MIT", "peer": true, "dependencies": { @@ -33681,6 +34616,8 @@ }, "node_modules/tinyqueue": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", "license": "ISC", "peer": true }, @@ -33734,11 +34671,15 @@ }, "node_modules/to-float32": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", + "integrity": "sha512-keDnAusn/vc+R3iEiSDw8TOF7gPiTLdK1ArvWtYbJQiVfmRg6i/CAvbKq3uIS0vWroAC7ZecN3DjQKw3aSklUg==", "license": "MIT", "peer": true }, "node_modules/to-px": { - "version": "1.0.1", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-px/-/to-px-1.1.0.tgz", + "integrity": "sha512-bfg3GLYrGoEzrGoE05TAL/Uw+H/qrf2ptr9V3W7U0lkjjyYnIfgxmVLUfhQ1hZpIQwin81uxhDjvUkDYsC0xWw==", "license": "MIT", "peer": true, "dependencies": { @@ -33776,6 +34717,8 @@ }, "node_modules/topojson-client": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", "license": "ISC", "peer": true, "dependencies": { @@ -33789,6 +34732,8 @@ }, "node_modules/topojson-client/node_modules/commander": { "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT", "peer": true }, @@ -33886,6 +34831,8 @@ }, "node_modules/ts-interface-checker": { "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0", "peer": true }, @@ -34043,6 +34990,8 @@ }, "node_modules/ts-node-dev/node_modules/chokidar": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -34088,6 +35037,8 @@ }, "node_modules/ts-node-dev/node_modules/glob-parent": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -34125,6 +35076,8 @@ }, "node_modules/ts-node-dev/node_modules/readdirp": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", "dependencies": { @@ -34247,6 +35200,8 @@ }, "node_modules/type": { "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", "license": "ISC", "peer": true }, @@ -34361,6 +35316,8 @@ }, "node_modules/typedarray-pool": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", + "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", "license": "MIT", "peer": true, "dependencies": { @@ -34370,6 +35327,8 @@ }, "node_modules/typescript": { "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -34728,6 +35687,8 @@ }, "node_modules/unquote": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", "license": "MIT", "peer": true }, @@ -34812,6 +35773,8 @@ }, "node_modules/update-diff": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-diff/-/update-diff-1.1.0.tgz", + "integrity": "sha512-rCiBPiHxZwT4+sBhEbChzpO5hYHjm91kScWgdHf4Qeafs6Ba7MBl+d9GlGv72bcTZQO0sLmtQS1pHSWoCLtN/A==", "license": "MIT", "peer": true }, @@ -35636,6 +36599,8 @@ }, "node_modules/vt-pbf": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", "license": "MIT", "peer": true, "dependencies": { @@ -35678,11 +36643,12 @@ } }, "node_modules/watchpack": { - "version": "2.5.1", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "license": "MIT", "peer": true, "dependencies": { - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" }, "engines": { @@ -35699,6 +36665,8 @@ }, "node_modules/weak-map": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", + "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", "license": "Apache-2.0", "peer": true }, @@ -35715,6 +36683,8 @@ }, "node_modules/webgl-context": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", + "integrity": "sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==", "license": "MIT", "peer": true, "dependencies": { @@ -35729,35 +36699,32 @@ } }, "node_modules/webpack": { - "version": "5.105.2", + "version": "5.109.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", + "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", "license": "MIT", "peer": true, "dependencies": { - "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", + "acorn": "^8.16.0", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.19.0", - "es-module-lexer": "^2.0.0", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.3" + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" }, "bin": { "webpack": "bin/webpack.js" @@ -35776,7 +36743,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.4", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "license": "MIT", "peer": true, "engines": { @@ -35791,7 +36760,9 @@ "license": "MIT" }, "node_modules/webpack/node_modules/ajv": { - "version": "8.18.0", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "peer": true, "dependencies": { @@ -35807,6 +36778,8 @@ }, "node_modules/webpack/node_modules/ajv-keywords": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", "peer": true, "dependencies": { @@ -35816,13 +36789,10 @@ "ajv": "^8.8.2" } }, - "node_modules/webpack/node_modules/es-module-lexer": { - "version": "2.0.0", - "license": "MIT", - "peer": true - }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -35835,24 +36805,25 @@ }, "node_modules/webpack/node_modules/estraverse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", "peer": true, "engines": { "node": ">=4.0" } }, - "node_modules/webpack/node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "license": "MIT", - "peer": true - }, "node_modules/webpack/node_modules/json-schema-traverse": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT", "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", "peer": true, "dependencies": { @@ -36066,6 +37037,8 @@ }, "node_modules/world-calendars": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", + "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", "license": "MIT", "peer": true, "dependencies": { @@ -36403,7 +37376,6 @@ }, "node_modules/zod": { "version": "4.3.6", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -36543,6 +37515,7 @@ "default-composer": "^0.6.0", "dompurify": "^3.4.10", "echarts": "^6.0.0", + "elkjs": "^0.12.0", "fetch-retry": "^6.0.0", "file-saver": "^2.0.5", "filesize": "^11.0.13", @@ -36578,7 +37551,8 @@ "tailwind-styled-components": "^2.2.0", "use-deep-compare": "^1.3.0", "victory": "^37.3.6", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "devDependencies": { "@iconify/types": "^2.0.0", @@ -36695,8 +37669,7 @@ "peerDependencies": { "tailwindcss": "^3.4.16", "ts-jest": "^29.1.2", - "ts-node": "^10.9.2", - "typescript": "^5.6.2" + "ts-node": "^10.9.2" } }, "packages/storybook": { @@ -36728,8 +37701,7 @@ "peerDependencies": { "tailwindcss": "^3.4.16", "ts-jest": "^29.1.2", - "ts-node": "^10.9.2", - "typescript": "^5.6.2" + "ts-node": "^10.9.2" } }, "packages/storybook/node_modules/dotenv": { diff --git a/package.json b/package.json index 436d4b21..7764805b 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@types/jest": "^30.0.0", "@types/node": "25.3.0", "@types/react": "^19.2.14", + "@typescript/native": "npm:typescript@^7.0.2", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "@welldone-software/why-did-you-render": "^10.0.1", diff --git a/packages/core/package.json b/packages/core/package.json index e12ad61b..9a51bf71 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,12 +25,12 @@ }, "scripts": { "lint": "eslint .", - "compile": "tsc", + "compile": "node ../../node_modules/@typescript/native/bin/tsc", "clean": "rm -rf dist", - "types": "tsc --emitDeclarationOnly --skipLibCheck", + "types": "node ../../node_modules/@typescript/native/bin/tsc --emitDeclarationOnly --skipLibCheck", "build": "npm run clean && npm run compile && rollup --config rollup.config.mjs", "build:clean": "npm run clean && npm run compile && npm run types && rollup --config rollup.config.mjs", - "build:watch": "npm run compile && npm run build -- --watch", + "build:watch": "npm run compile && rollup --config rollup.config.mjs --watch", "test": "jest unit", "test:watch": "jest unit --watch", "test:int": "jest int", @@ -68,4 +68,4 @@ "files": [ "dist" ] -} \ No newline at end of file +} diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index f9d1035b..aa8a4f79 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1,5 +1,5 @@ export const GEN3_COMMONS_NAME = - process.env.NEXT_PUBLIC_GEN3_COMMONS_NAME || 'gen3'; + process.env.NEXT_PUBLIC_GEN3_COMMONS_NAME || 'cbds'; export const GEN3_API = process.env.NEXT_PUBLIC_GEN3_API || ''; export const GEN3_DOMAIN = process.env.NEXT_PUBLIC_GEN3_DOMAIN || ''; export const GUID_PREFIX_PATTERN = /^dg.[a-zA-Z0-9]+\//; @@ -9,6 +9,8 @@ export const GUID_PREFIX_PATTERN = /^dg.[a-zA-Z0-9]+\//; */ export const GEN3_GUPPY_API = process.env.NEXT_PUBLIC_GEN3_GUPPY_API || `${GEN3_API}/guppy`; +export const GEN3_LOOM_API = + process.env.NEXT_PUBLIC_GEN3_LOOM_API || `${GEN3_API}/loom`; export const GEN3_MDS_API = process.env.NEXT_PUBLIC_GEN3_MDS_API || `${GEN3_API}/mds`; export const GEN3_DOWNLOADS_ENDPOINT = @@ -54,7 +56,7 @@ export const DIR_SEARCH_API = export const CALYPR_EXPLORER_CONFIG_API = process.env.NEXT_PUBLIC_GEN3_CONFIG_API || `${GEN3_API}/gecko`; export const GEN3_GECKO_API = - process.env.NEXT_PUBLIC_GEN3_GECKO_API || `${GEN3_API}/api`; + process.env.NEXT_PUBLIC_GEN3_GECKO_API || `${GEN3_API}/gecko`; export enum Accessibility { ACCESSIBLE = 'accessible', diff --git a/packages/core/src/features/explorerBuilder/explorerBuilderApi.ts b/packages/core/src/features/explorerBuilder/explorerBuilderApi.ts new file mode 100644 index 00000000..02083ec0 --- /dev/null +++ b/packages/core/src/features/explorerBuilder/explorerBuilderApi.ts @@ -0,0 +1,562 @@ +import type { + FetchBaseQueryError, + FetchBaseQueryMeta, +} from '@reduxjs/toolkit/query'; +import { GEN3_GECKO_API } from '../../constants'; +import { gen3Api } from '../gen3'; +import type { + BuilderApiError, + BuilderDiagnostic, + BuilderProject, + ExplorerAuthoringDocument, + ExplorerBuilderState, + ExplorerConfigRevision, + ExplorerRelease, + ProjectRecipeDraft, + ProjectRecipeRevision, + RecipeAuthoringDocument, + RecipeDraftPreview, + RecipeDraftValidation, + ResolvedExplorerRelease, +} from './types'; +import type { LoomColumn } from '../loom'; +import type { JSONValue } from '../../types'; + +type ProjectArgs = BuilderProject; +type ConfigArgs = ProjectArgs & { readonly configId: string }; + +const segment = (value: string) => encodeURIComponent(value); +const projectPath = ({ organization, project }: ProjectArgs) => + `${GEN3_GECKO_API}/builder/projects/${segment(organization)}/${segment(project)}`; +const explorerPath = (args: ConfigArgs) => + `${projectPath(args)}/explorers/${segment(args.configId)}`; + +export const explorerRevisionCollectionPath = (args: ConfigArgs) => + `${explorerPath(args)}/revisions`; + +export const explorerRevisionPath = ( + args: ConfigArgs & { readonly revisionId: string }, +) => `${explorerRevisionCollectionPath(args)}/${segment(args.revisionId)}`; + +export const activateExplorerRevisionPath = ( + args: ConfigArgs & { readonly revisionId: string }, +) => `${explorerRevisionPath(args)}/activate`; + +export const buildExplorerDraftBody = (draft: ExplorerAuthoringDocument) => ({ + config: draft, +}); + +export const buildExplorerValidationBody = ( + draft: ExplorerAuthoringDocument, + recipeRevisionId: string, +) => ({ + ...buildExplorerDraftBody(draft), + recipeRevisionId, +}); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +export const normalizeBuilderApiError = ( + response: FetchBaseQueryError, + meta?: FetchBaseQueryMeta, +): BuilderApiError => { + const data = isRecord(response.data) ? response.data : {}; + const rawDiagnostics = Array.isArray(data.diagnostics) + ? data.diagnostics + : []; + const requestId = + (typeof data.requestId === 'string' ? data.requestId : undefined) ?? + meta?.response?.headers.get('x-request-id') ?? + undefined; + const diagnostics: BuilderDiagnostic[] = rawDiagnostics + .filter(isRecord) + .map((diagnostic) => ({ + severity: diagnostic.severity === 'warning' ? 'warning' : 'error', + code: + typeof diagnostic.code === 'string' ? diagnostic.code : 'BUILDER_ERROR', + configPath: + typeof diagnostic.configPath === 'string' + ? diagnostic.configPath + : typeof diagnostic.fieldPath === 'string' + ? diagnostic.fieldPath + : undefined, + message: + typeof diagnostic.message === 'string' + ? diagnostic.message + : 'The builder request failed.', + retryable: + typeof diagnostic.retryable === 'boolean' + ? diagnostic.retryable + : undefined, + requestId: + typeof diagnostic.requestId === 'string' + ? diagnostic.requestId + : requestId, + details: isRecord(diagnostic.details) ? diagnostic.details : undefined, + })); + const numericStatus = + typeof response.status === 'number' ? response.status : undefined; + return { + status: response.status, + requestId, + retryable: + typeof data.retryable === 'boolean' + ? data.retryable + : numericStatus === 408 || + numericStatus === 425 || + numericStatus === 429 || + (numericStatus !== undefined && numericStatus >= 500), + diagnostics: + diagnostics.length > 0 + ? diagnostics + : [ + { + severity: 'error', + code: typeof data.code === 'string' ? data.code : 'BUILDER_ERROR', + message: + typeof data.message === 'string' + ? data.message + : typeof data.error === 'string' + ? data.error + : 'The builder request failed.', + requestId, + }, + ], + currentVersion: + typeof data.currentVersion === 'number' ? data.currentVersion : undefined, + currentDigest: + typeof data.currentDigest === 'string' ? data.currentDigest : undefined, + updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined, + }; +}; + +const errorResponse = ( + response: FetchBaseQueryError, + meta: FetchBaseQueryMeta | undefined, +) => normalizeBuilderApiError(response, meta); + +type UnknownRecord = Record; + +const asRecord = (value: unknown): UnknownRecord => + isRecord(value) ? value : {}; + +const normalizeColumn = (value: unknown): LoomColumn => { + if (typeof value === 'string') { + return { + name: value, + clickhouseType: 'String', + logicalType: 'string', + nullable: false, + repeated: false, + filterable: true, + sortable: true, + aggregatable: false, + }; + } + const column = asRecord(value); + const logicalType = + typeof column.logicalType === 'string' + ? column.logicalType + : typeof column.type === 'string' + ? column.type + : 'string'; + return { + name: typeof column.name === 'string' ? column.name : '', + clickhouseType: + typeof column.clickhouseType === 'string' + ? column.clickhouseType + : logicalType, + logicalType, + nullable: column.nullable === true, + repeated: column.repeated === true, + filterable: column.filterable !== false, + sortable: column.sortable !== false, + aggregatable: column.aggregatable === true, + }; +}; + +const normalizeColumns = (value: unknown): LoomColumn[] => + Array.isArray(value) ? value.map(normalizeColumn) : []; + +export const normalizeProjectRecipeDraft = ( + value: unknown, +): ProjectRecipeDraft => { + const draft = asRecord(value); + const draftVersion = + typeof draft.draftVersion === 'number' ? draft.draftVersion : 0; + return { + ...(draft as unknown as ProjectRecipeDraft), + source: + draft.source === 'project-draft' || draftVersion > 0 + ? 'project-draft' + : 'platform-default', + draftVersion, + document: asRecord(draft.document) as ProjectRecipeDraft['document'], + authoringDigest: + typeof draft.authoringDigest === 'string' ? draft.authoringDigest : '', + }; +}; + +export const normalizeRecipeDraftValidation = ( + value: unknown, +): RecipeDraftValidation => { + const raw = asRecord(value); + const outputs = Array.isArray(raw.outputs) ? raw.outputs : []; + return { + recipeDigest: + typeof raw.recipeDigest === 'string' ? raw.recipeDigest : undefined, + resolvedSchemaDigest: + typeof raw.resolvedSchemaDigest === 'string' + ? raw.resolvedSchemaDigest + : undefined, + sourceGeneration: + typeof raw.sourceGeneration === 'string' + ? raw.sourceGeneration + : undefined, + outputs: outputs.map((candidate) => { + const output = asRecord(candidate); + const fieldNames = Array.isArray(output.fieldNames) + ? output.fieldNames + .filter((field): field is string => typeof field === 'string') + .map((name) => ({ name })) + : []; + return { + name: typeof output.name === 'string' ? output.name : '', + rootResourceType: + typeof output.rootResourceType === 'string' + ? output.rootResourceType + : '', + rowGrain: typeof output.rowGrain === 'string' ? output.rowGrain : '', + columns: normalizeColumns(output.columns ?? fieldNames), + }; + }), + diagnostics: Array.isArray(raw.diagnostics) + ? (raw.diagnostics as RecipeDraftValidation['diagnostics']) + : [], + }; +}; + +export const normalizeRecipeDraftPreview = ( + value: unknown, + requestedOutput: string, +): RecipeDraftPreview => { + const raw = asRecord(value); + const nested = isRecord(raw.preview) ? raw.preview : raw; + const rawOutputs = Array.isArray(nested.outputs) ? nested.outputs : []; + const selected = + rawOutputs.find( + (candidate) => asRecord(candidate).name === requestedOutput, + ) ?? rawOutputs[0]; + const output = asRecord(selected); + const outputName = + typeof nested.output === 'string' + ? nested.output + : typeof output.name === 'string' + ? output.name + : requestedOutput; + const rowsValue = nested.rows ?? output.rows; + let rows: ReadonlyArray>> = []; + if (Array.isArray(rowsValue)) { + rows = rowsValue.filter(isRecord) as ReadonlyArray< + Readonly> + >; + } else if (typeof rowsValue === 'string') { + try { + const parsed: unknown = JSON.parse(rowsValue); + if (Array.isArray(parsed)) { + rows = parsed.filter(isRecord) as ReadonlyArray< + Readonly> + >; + } + } catch { + rows = []; + } + } + const columns = normalizeColumns(nested.columns ?? output.columns); + const validation = normalizeRecipeDraftValidation( + nested.validation ?? { + recipeDigest: nested.recipeDigest, + resolvedSchemaDigest: nested.resolvedSchemaDigest, + sourceGeneration: nested.sourceGeneration, + outputs: rawOutputs, + diagnostics: nested.diagnostics, + }, + ); + return { + validation, + output: outputName, + columns, + rows, + rowCount: + typeof nested.rowCount === 'number' + ? nested.rowCount + : typeof output.rowCount === 'number' + ? output.rowCount + : rows.length, + }; +}; + +const unwrapExplorers = ( + value: unknown, +): ReadonlyArray => { + const raw = asRecord(value); + return ( + Array.isArray(raw.explorers) ? raw.explorers : value + ) as ReadonlyArray; +}; + +export const explorerBuilderApi = gen3Api + .enhanceEndpoints({ addTagTypes: ['BuilderRecipe', 'BuilderExplorer'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getProjectRecipe: builder.query({ + query: (args) => `${projectPath(args)}/recipe`, + transformResponse: normalizeProjectRecipeDraft, + transformErrorResponse: errorResponse, + providesTags: (_result, _error, args) => [ + { type: 'BuilderRecipe', id: `${args.organization}/${args.project}` }, + ], + }), + saveProjectRecipeDraft: builder.mutation< + ProjectRecipeDraft, + ProjectArgs & { + readonly draft: RecipeAuthoringDocument; + readonly expectedDraftVersion: number; + } + >({ + query: ({ draft, expectedDraftVersion, ...project }) => ({ + url: `${projectPath(project)}/recipe/draft`, + method: 'PUT', + headers: { 'If-Match': `"${expectedDraftVersion}"` }, + body: { recipe: draft }, + }), + transformResponse: normalizeProjectRecipeDraft, + transformErrorResponse: errorResponse, + invalidatesTags: (_result, _error, args) => [ + { type: 'BuilderRecipe', id: `${args.organization}/${args.project}` }, + ], + }), + validateProjectRecipe: builder.mutation< + RecipeDraftValidation, + ProjectArgs & { readonly recipe: RecipeAuthoringDocument } + >({ + query: ({ recipe, ...project }) => ({ + url: `${projectPath(project)}/recipe/validate`, + method: 'POST', + body: { recipe }, + }), + transformResponse: normalizeRecipeDraftValidation, + transformErrorResponse: errorResponse, + }), + previewProjectRecipe: builder.mutation< + RecipeDraftPreview, + ProjectArgs & { + readonly recipe: RecipeAuthoringDocument; + readonly output: string; + readonly limit: 10 | 25 | 50 | 100; + } + >({ + query: ({ recipe, output, limit, ...project }) => ({ + url: `${projectPath(project)}/recipe/preview`, + method: 'POST', + body: { recipe, output, limit }, + }), + transformResponse: (response, _meta, args) => + normalizeRecipeDraftPreview(response, args.output), + transformErrorResponse: errorResponse, + }), + publishProjectRecipe: builder.mutation< + ProjectRecipeRevision, + ProjectArgs & { + readonly expectedDraftVersion: number; + readonly expectedAuthoringDigest: string; + readonly outputs?: ReadonlyArray; + } + >({ + query: ({ + expectedDraftVersion, + expectedAuthoringDigest, + outputs, + ...project + }) => ({ + url: `${projectPath(project)}/recipe/publish`, + method: 'POST', + body: { expectedDraftVersion, expectedAuthoringDigest, outputs }, + }), + transformErrorResponse: errorResponse, + invalidatesTags: (_result, _error, args) => [ + { type: 'BuilderRecipe', id: `${args.organization}/${args.project}` }, + ], + }), + getProjectRecipeRevisions: builder.query< + ReadonlyArray, + ProjectArgs + >({ + query: (args) => `${projectPath(args)}/recipe/revisions`, + transformErrorResponse: errorResponse, + }), + getProjectRecipeRevision: builder.query< + ProjectRecipeRevision, + ProjectArgs & { readonly revisionId: string } + >({ + query: ({ revisionId, ...project }) => + `${projectPath(project)}/recipe/revisions/${segment(revisionId)}`, + transformErrorResponse: errorResponse, + }), + getExplorers: builder.query< + ReadonlyArray, + ProjectArgs + >({ + query: (args) => `${projectPath(args)}/explorers`, + transformResponse: unwrapExplorers, + transformErrorResponse: errorResponse, + providesTags: (_result, _error, args) => [ + { + type: 'BuilderExplorer', + id: `${args.organization}/${args.project}`, + }, + ], + }), + createExplorer: builder.mutation< + ExplorerBuilderState, + ProjectArgs & { readonly configId: string; readonly title: string } + >({ + query: ({ configId, title, ...project }) => ({ + url: `${projectPath(project)}/explorers`, + method: 'POST', + body: { configId, title }, + }), + transformErrorResponse: errorResponse, + invalidatesTags: (_result, _error, args) => [ + { + type: 'BuilderExplorer', + id: `${args.organization}/${args.project}`, + }, + ], + }), + getExplorer: builder.query({ + query: explorerPath, + transformErrorResponse: errorResponse, + }), + renameExplorer: builder.mutation< + ExplorerBuilderState, + ConfigArgs & { readonly title: string } + >({ + query: ({ title, ...args }) => ({ + url: explorerPath(args), + method: 'PUT', + body: { title }, + }), + transformErrorResponse: errorResponse, + invalidatesTags: (_result, _error, args) => [ + { + type: 'BuilderExplorer', + id: `${args.organization}/${args.project}`, + }, + ], + }), + saveExplorerDraft: builder.mutation< + ExplorerBuilderState, + ConfigArgs & { + readonly draft: ExplorerAuthoringDocument; + readonly expectedDraftVersion: number; + } + >({ + query: ({ draft, expectedDraftVersion, ...args }) => ({ + url: `${explorerPath(args)}/draft`, + method: 'PUT', + headers: { 'If-Match': `"${expectedDraftVersion}"` }, + body: buildExplorerDraftBody(draft), + }), + transformErrorResponse: errorResponse, + }), + validateExplorer: builder.mutation< + { readonly diagnostics: ReadonlyArray }, + ConfigArgs & { + readonly draft: ExplorerAuthoringDocument; + readonly recipeRevisionId: string; + } + >({ + query: ({ draft, recipeRevisionId, ...args }) => ({ + url: `${explorerPath(args)}/validate`, + method: 'POST', + body: buildExplorerValidationBody(draft, recipeRevisionId), + }), + transformErrorResponse: errorResponse, + }), + publishExplorer: builder.mutation< + ExplorerConfigRevision, + ConfigArgs & { + readonly expectedDraftVersion: number; + readonly recipeRevisionId: string; + } + >({ + query: ({ expectedDraftVersion, recipeRevisionId, ...args }) => ({ + url: `${explorerPath(args)}/publish`, + method: 'POST', + body: { expectedDraftVersion, recipeRevisionId }, + }), + transformErrorResponse: errorResponse, + }), + getExplorerRevisions: builder.query< + ReadonlyArray, + ConfigArgs + >({ + query: explorerRevisionCollectionPath, + transformErrorResponse: errorResponse, + }), + getExplorerRevision: builder.query< + ExplorerConfigRevision, + ConfigArgs & { readonly revisionId: string } + >({ + query: ({ revisionId, ...args }) => + explorerRevisionPath({ ...args, revisionId }), + transformErrorResponse: errorResponse, + }), + activateExplorerRevision: builder.mutation< + ExplorerRelease, + ConfigArgs & { + readonly revisionId: string; + readonly expectedActiveReleaseId: string | null; + } + >({ + query: ({ revisionId, expectedActiveReleaseId, ...args }) => ({ + url: activateExplorerRevisionPath({ ...args, revisionId }), + method: 'POST', + body: { expectedActiveReleaseId }, + }), + transformErrorResponse: errorResponse, + }), + getResolvedExplorerRelease: builder.query< + ResolvedExplorerRelease, + string + >({ + query: (releaseId) => + `${GEN3_GECKO_API}/explorer/releases/${segment(releaseId)}/resolved`, + transformErrorResponse: errorResponse, + keepUnusedDataFor: 300, + }), + }), + }); + +export const { + useGetProjectRecipeQuery, + useSaveProjectRecipeDraftMutation, + useValidateProjectRecipeMutation, + usePreviewProjectRecipeMutation, + usePublishProjectRecipeMutation, + useGetProjectRecipeRevisionsQuery, + useGetProjectRecipeRevisionQuery, + useGetExplorersQuery, + useCreateExplorerMutation, + useGetExplorerQuery, + useRenameExplorerMutation, + useSaveExplorerDraftMutation, + useValidateExplorerMutation, + usePublishExplorerMutation, + useGetExplorerRevisionsQuery, + useGetExplorerRevisionQuery, + useActivateExplorerRevisionMutation, + useGetResolvedExplorerReleaseQuery, +} = explorerBuilderApi; diff --git a/packages/core/src/features/explorerBuilder/explorerBuilderApi.unit.test.ts b/packages/core/src/features/explorerBuilder/explorerBuilderApi.unit.test.ts new file mode 100644 index 00000000..db045ab8 --- /dev/null +++ b/packages/core/src/features/explorerBuilder/explorerBuilderApi.unit.test.ts @@ -0,0 +1,112 @@ +import { + activateExplorerRevisionPath, + buildExplorerDraftBody, + buildExplorerValidationBody, + explorerRevisionCollectionPath, + explorerRevisionPath, + normalizeBuilderApiError, + normalizeProjectRecipeDraft, + normalizeRecipeDraftPreview, +} from './explorerBuilderApi'; +import { GEN3_GECKO_API } from '../../constants'; + +describe('normalizeBuilderApiError', () => { + it('preserves conflict metadata and maps Loom fieldPath to configPath', () => { + expect( + normalizeBuilderApiError({ + status: 409, + data: { + currentVersion: 7, + currentDigest: 'sha256-current', + updatedAt: '2026-08-11T00:00:00Z', + diagnostics: [ + { + severity: 'error', + code: 'DRAFT_CONFLICT', + fieldPath: 'outputs.0.fields.2', + message: 'The draft changed.', + }, + ], + }, + }), + ).toMatchObject({ + status: 409, + retryable: false, + currentVersion: 7, + currentDigest: 'sha256-current', + diagnostics: [{ configPath: 'outputs.0.fields.2' }], + }); + }); + + it('marks transient infrastructure failures retryable', () => { + expect( + normalizeBuilderApiError({ status: 503, data: undefined }).retryable, + ).toBe(true); + }); +}); + +describe('builder wire-shape normalization', () => { + const explorer = { + organization: 'acme', + project: 'cancer-study', + configId: 'patient-overview', + } as const; + + it('uses revision collection and revision-specific activation routes', () => { + expect(explorerRevisionCollectionPath(explorer)).toBe( + `${GEN3_GECKO_API}/builder/projects/acme/cancer-study/explorers/patient-overview/revisions`, + ); + expect(explorerRevisionPath({ ...explorer, revisionId: 'rev-1' })).toBe( + `${GEN3_GECKO_API}/builder/projects/acme/cancer-study/explorers/patient-overview/revisions/rev-1`, + ); + expect( + activateExplorerRevisionPath({ ...explorer, revisionId: 'rev-1' }), + ).toContain('/revisions/rev-1/activate'); + }); + + it('keeps Explorer authoring under the frozen config field', () => { + expect(buildExplorerDraftBody({ schemaVersion: 1, tabs: [] })).toEqual({ + config: { schemaVersion: 1, tabs: [] }, + }); + expect( + buildExplorerValidationBody({ schemaVersion: 1, tabs: [] }, 'rev-1'), + ).toEqual({ + config: { schemaVersion: 1, tabs: [] }, + recipeRevisionId: 'rev-1', + }); + }); + + it('marks a version-zero default draft as platform-default', () => { + expect( + normalizeProjectRecipeDraft({ + project: 'acme/cancer-study', + draftVersion: 0, + document: { translationVersion: 'draft' }, + authoringDigest: 'sha256:default', + }), + ).toMatchObject({ source: 'platform-default', draftVersion: 0 }); + }); + + it('selects the requested output from Loom preview outputs', () => { + expect( + normalizeRecipeDraftPreview( + { + name: 'project_recipe', + recipeDigest: 'sha256:recipe', + resolvedSchemaDigest: 'sha256:schema', + sourceGeneration: 'generation-42', + outputs: [ + { name: 'Other', columns: ['id'], rows: [{ id: 'other' }] }, + { name: 'Patients', columns: ['id'], rows: [{ id: 'patient-1' }] }, + ], + }, + 'Patients', + ), + ).toMatchObject({ + output: 'Patients', + columns: [{ name: 'id', logicalType: 'string' }], + rows: [{ id: 'patient-1' }], + validation: { recipeDigest: 'sha256:recipe' }, + }); + }); +}); diff --git a/packages/core/src/features/explorerBuilder/index.ts b/packages/core/src/features/explorerBuilder/index.ts new file mode 100644 index 00000000..e13da641 --- /dev/null +++ b/packages/core/src/features/explorerBuilder/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './explorerBuilderApi'; +export * from './semanticConcepts'; diff --git a/packages/core/src/features/explorerBuilder/semanticConcepts.ts b/packages/core/src/features/explorerBuilder/semanticConcepts.ts new file mode 100644 index 00000000..0552cc12 --- /dev/null +++ b/packages/core/src/features/explorerBuilder/semanticConcepts.ts @@ -0,0 +1,295 @@ +import { GEN3_LOOM_API } from '../../constants'; +import { fetchGraphQL } from '../loom'; +import type { + SemanticCatalogCompleteness, + SemanticCatalogDiagnostic, + SemanticConcept, + SemanticConceptCatalog, + SemanticConceptColumn, + SemanticConceptExamples, + SemanticConceptFamily, + SemanticConceptPopulation, + SemanticConceptRepetition, + SemanticConceptResource, + SemanticConceptSelector, + SemanticConceptSource, + RecipeColumnCandidate, + RecipeColumnCandidateConnection, +} from './types'; + +type RecordValue = Record; + +const isRecord = (value: unknown): value is RecordValue => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const record = (value: unknown): RecordValue => (isRecord(value) ? value : {}); + +const string = (value: unknown): string | undefined => + typeof value === 'string' && value.trim() ? value : undefined; + +const strings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + +const normalizeSource = (value: unknown): SemanticConceptSource | undefined => { + if (!isRecord(value)) return undefined; + const source = { ...value } as SemanticConceptSource; + const terminology = record(value.terminology); + return { + ...source, + system: string(value.system), + standardVersion: string(value.standardVersion), + kind: string(value.kind), + resourceType: string(value.resourceType), + keyPaths: strings(value.keyPaths), + valuePaths: strings(value.valuePaths), + logicalType: string(value.logicalType), + terminology: Object.keys(terminology).length + ? Object.fromEntries(Object.entries(terminology).filter((entry) => typeof entry[1] === 'string')) as Record + : undefined, + }; +}; + +const normalizeColumn = (value: unknown, fallbackName: string): SemanticConceptColumn => { + const column = record(value); + return { + ...column, + name: string(column.name) ?? fallbackName, + logicalType: string(column.logicalType), + nullable: column.nullable !== false, + repeated: column.repeated === true, + filterable: column.filterable !== false, + sortable: column.sortable !== false, + aggregatable: column.aggregatable === true, + }; +}; + +const normalizeSelector = (value: unknown): SemanticConceptSelector | undefined => { + if (!isRecord(value)) return undefined; + return { ...value, sourcePath: string(value.sourcePath), valuePath: string(value.valuePath) }; +}; + +const normalizePopulation = (value: unknown): SemanticConceptPopulation | undefined => { + if (!isRecord(value)) return undefined; + return { + ...value, + recordCount: typeof value.recordCount === 'number' ? value.recordCount : undefined, + fraction: typeof value.fraction === 'number' ? value.fraction : undefined, + }; +}; + +const normalizeExamples = (value: unknown): SemanticConceptExamples | undefined => { + if (!isRecord(value)) return undefined; + return { + ...value, + values: Array.isArray(value.values) ? value.values : undefined, + suppressed: value.suppressed === true, + reason: string(value.reason), + }; +}; + +const normalizeRepetition = (value: unknown): SemanticConceptRepetition | undefined => { + if (!isRecord(value)) return undefined; + return { + ...value, + shape: string(value.shape), + rowExpansion: string(value.rowExpansion), + maxItemsObserved: typeof value.maxItemsObserved === 'number' ? value.maxItemsObserved : undefined, + }; +}; + +export const normalizeSemanticConcept = (value: unknown): SemanticConcept | undefined => { + const concept = record(value); + const id = string(concept.id); + const ruleId = string(concept.ruleId); + if (!id || !ruleId) return undefined; + const output = record(concept.output); + const selection = record(output.selection); + const rawColumn = record(concept.column); + const rawExamples = record(concept.examples); + const rawSource = record(concept.source); + const selector = isRecord(concept.selector) + ? concept.selector + : isRecord(output.selection) + ? { sourcePath: selection.sourcePath, valuePath: selection.valueSelector } + : undefined; + const fallbackColumn = { + name: id.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, ''), + logicalType: string(output.valueType) ?? string(rawSource.primitive), + repeated: rawSource.repeated === true || /repeated|array|pivot/i.test(string(output.cardinality) ?? ''), + }; + return { + ...concept, + id, + label: string(concept.label) ?? id, + family: string(concept.family), + ruleId, + description: string(concept.description), + source: normalizeSource(concept.source), + selector: normalizeSelector(selector), + column: normalizeColumn(Object.keys(rawColumn).length ? rawColumn : fallbackColumn, fallbackColumn.name), + population: normalizePopulation(concept.population) ?? (typeof rawSource.populationCount === 'number' + ? { recordCount: rawSource.populationCount } + : undefined), + examples: normalizeExamples(concept.examples) ?? (Object.keys(rawExamples).length + ? { ...rawExamples, values: Array.isArray(rawExamples.values) ? rawExamples.values : undefined, suppressed: rawExamples.suppressed === true } + : undefined), + repetition: normalizeRepetition(concept.repetition) ?? (fallbackColumn.repeated + ? { shape: 'array', rowExpansion: 'none' } + : undefined), + }; +}; + +const normalizeFamily = (value: unknown): SemanticConceptFamily | undefined => { + const family = record(value); + const id = string(family.id) ?? string(family.label); + if (!id) return undefined; + return { + ...family, + id, + label: string(family.label), + concepts: (Array.isArray(family.concepts) ? family.concepts : []) + .map(normalizeSemanticConcept) + .map((concept) => concept && (!concept.family ? { ...concept, family: id } : concept)) + .filter((concept): concept is SemanticConcept => Boolean(concept)), + }; +}; + +const normalizeResource = (value: unknown): SemanticConceptResource | undefined => { + const resource = record(value); + const resourceType = string(resource.resourceType); + if (!resourceType) return undefined; + return { + ...resource, + resourceType, + label: string(resource.label), + documentCount: typeof resource.documentCount === 'number' ? resource.documentCount : undefined, + families: (Array.isArray(resource.families) ? resource.families : []) + .map(normalizeFamily) + .filter((family): family is SemanticConceptFamily => Boolean(family)), + }; +}; + +const normalizeCompleteness = (value: unknown): SemanticCatalogCompleteness | undefined => { + if (!isRecord(value)) return undefined; + return { + ...value, + state: string(value.state), + resourceLimit: typeof value.resourceLimit === 'number' ? value.resourceLimit : undefined, + conceptLimitPerResource: typeof value.conceptLimitPerResource === 'number' ? value.conceptLimitPerResource : undefined, + returnedResourceCount: typeof value.returnedResourceCount === 'number' ? value.returnedResourceCount : undefined, + returnedConceptCount: typeof value.returnedConceptCount === 'number' ? value.returnedConceptCount : undefined, + }; +}; + +const normalizeDiagnostic = (value: unknown): SemanticCatalogDiagnostic => { + const diagnostic = record(value); + return { + severity: diagnostic.severity === 'error' || diagnostic.severity === 'info' ? diagnostic.severity : 'warning', + code: string(diagnostic.code) ?? 'SEMANTIC_CATALOG_DIAGNOSTIC', + message: string(diagnostic.message) ?? 'Semantic catalog disclosure', + retryable: typeof diagnostic.retryable === 'boolean' ? diagnostic.retryable : undefined, + details: isRecord(diagnostic.details) ? diagnostic.details : undefined, + }; +}; + +/** Normalize the v2 contract while tolerating additive producer fields. */ +export const normalizeSemanticConceptCatalog = (value: unknown): SemanticConceptCatalog => { + const wrapper = record(value); + const raw = isRecord(wrapper.catalog) ? wrapper.catalog : wrapper; + return { + schemaVersion: typeof raw.schemaVersion === 'number' ? raw.schemaVersion : 2, + catalogId: string(raw.catalogId), + project: isRecord(raw.project) + ? { organization: string(raw.project.organization), project: string(raw.project.project) } + : undefined, + source: isRecord(raw.source) ? raw.source : undefined, + completeness: normalizeCompleteness(raw.completeness), + resources: (Array.isArray(raw.resources) ? raw.resources : []) + .map(normalizeResource) + .filter((resource): resource is SemanticConceptResource => Boolean(resource)), + diagnostics: (Array.isArray(raw.diagnostics) ? raw.diagnostics : []).map(normalizeDiagnostic), + }; +}; + +interface SemanticConceptCatalogResponse { + readonly semanticConceptCatalog?: unknown; + readonly dataframeBuilderSemanticCatalog?: unknown; +} + +/** + * Fetch the optional Loom v2 catalog. A missing endpoint is intentionally a + * normal error for callers: the guided builder falls back to its existing + * populated-field scan and labels those choices as technical fields. + */ +export const fetchSemanticConceptCatalog = async ( + project: string, + resourceType: string, + signal?: AbortSignal, +): Promise => { + const response = await fetchGraphQL( + { + query: `query BuilderSemanticCatalog($input: DataframeBuilderSemanticCatalogInput!) { + dataframeBuilderSemanticCatalog(input: $input) { + schemaVersion project sourceGeneration + completeness { state resourceLimit conceptLimitPerResource returnedResourceCount returnedConceptCount } + resources { + resourceType documentCount + families { id label ruleId concepts { + id label group description ruleId ruleVersion + source { canonical resourceType path profile sourcePath valuePath keySelector keySystem keyCode keyDisplay ruleVersion shape primitive repeated populationCount distinctTruncated } + output { mode valueType cardinality generic selection { mode sourcePath keySelector valueSelector valueFallbacks itemSource itemResourceType transforms key } } + examples { values limited } + } } + } + diagnostics { severity code ruleId path message } + } + }`, + variables: { input: { project, rootResourceType: resourceType } }, + }, + { endpoint: `${GEN3_LOOM_API}/graphql/graph`, signal }, + ); + return normalizeSemanticConceptCatalog(response.dataframeBuilderSemanticCatalog ?? response.semanticConceptCatalog); +}; + +interface RecipeColumnCandidateResponse { + readonly dataframeRecipeColumnCandidates?: RecipeColumnCandidateConnection; +} + +const columnCandidatesQuery = `query RecipeColumnCandidates($input: DataframeRecipeColumnCandidatesInput!) { + dataframeRecipeColumnCandidates(input: $input) { + nodes { + id output nodePath familyId familyKind familyName patchPath + rawKey selectionKey rawSystem rawCode extensionUrl publicName label valueSelector valueType cardinality + population examples selected complete diagnostic extensionMapping + } + pageInfo { hasNextPage endCursor } + completeness { complete totalCount returnedCount blockingDiagnosticCount } + diagnostics { severity code ruleId path message } + } +}`; + +/** Fetch every recipe-authorized candidate for one output traversal node. */ +export const fetchRecipeColumnCandidates = async ( + project: string, + recipe: unknown, + output: string, + nodePath: ReadonlyArray = [], + signal?: AbortSignal, +): Promise => { + const nodes: RecipeColumnCandidate[] = []; + let after: string | undefined; + let last: RecipeColumnCandidateConnection | undefined; + do { + const response = await fetchGraphQL( + { query: columnCandidatesQuery, variables: { input: { project, recipe, output, nodePath, first: 500, ...(after ? { after } : {}) } } }, + { endpoint: `${GEN3_LOOM_API}/graphql/graph`, signal }, + ); + const connection = response.dataframeRecipeColumnCandidates; + if (!connection) throw new Error('Loom returned no recipe column candidate connection'); + nodes.push(...connection.nodes); + last = connection; + after = connection.pageInfo.endCursor ?? undefined; + } while (last.pageInfo.hasNextPage && after); + if (!last) throw new Error('Loom returned no recipe column candidate connection'); + return { ...last, nodes, completeness: { ...last.completeness, returnedCount: nodes.length } }; +}; diff --git a/packages/core/src/features/explorerBuilder/semanticConcepts.unit.test.ts b/packages/core/src/features/explorerBuilder/semanticConcepts.unit.test.ts new file mode 100644 index 00000000..579e4141 --- /dev/null +++ b/packages/core/src/features/explorerBuilder/semanticConcepts.unit.test.ts @@ -0,0 +1,75 @@ +import { normalizeSemanticConceptCatalog } from './semanticConcepts'; + +describe('semantic concept contract v2 normalization', () => { + it('keeps open family/rule/type strings and safe metadata', () => { + const catalog = normalizeSemanticConceptCatalog({ + schemaVersion: 2, + completeness: { state: 'partial', returnedConceptCount: 2 }, + resources: [{ + resourceType: 'ObservationLike', + families: [{ + id: 'future-domain-v9', + label: 'Measurements', + concepts: [{ + id: 'observation.future-score', + label: 'Future clinical score', + family: 'future-domain-v9', + ruleId: 'future.source.rule.v8', + source: { system: 'FutureClinicalSource', kind: 'future-value-family' }, + column: { name: 'future_clinical_score', logicalType: 'futureDecimal128' }, + examples: { suppressed: true, reason: 'low-frequency-values', values: ['must-not-be-used'] }, + }], + }], + }], + diagnostics: [{ severity: 'warning', code: 'DISCOVERY_PARTIAL', message: 'partial' }], + }); + + const concept = catalog.resources[0].families[0].concepts[0]; + expect(catalog.completeness?.state).toBe('partial'); + expect(catalog.resources[0].families[0].id).toBe('future-domain-v9'); + expect(concept.ruleId).toBe('future.source.rule.v8'); + expect(concept.column.logicalType).toBe('futureDecimal128'); + expect(concept.examples?.suppressed).toBe(true); + expect(concept.examples?.values).toEqual(['must-not-be-used']); + }); + + it('drops malformed concepts and supplies stable column fallback names', () => { + const catalog = normalizeSemanticConceptCatalog({ + resources: [{ resourceType: 'Patient', families: [{ id: 'demographics', concepts: [ + { id: 'patient.birth-date', ruleId: 'direct.leaf.v1', label: 'Birth date', column: {} }, + { id: '', ruleId: 'missing' }, + ] }] }], + }); + expect(catalog.resources[0].families[0].concepts).toHaveLength(1); + expect(catalog.resources[0].families[0].concepts[0].column.name).toBe('patient_birth_date'); + }); + + it('adapts Loom GraphQL output metadata to the frozen v2 concept shape', () => { + const catalog = normalizeSemanticConceptCatalog({ + schemaVersion: 2, + project: 'acme-cancer', + sourceGeneration: 'generation-42', + resources: [{ + resourceType: 'Patient', + families: [{ + id: 'demographics', + concepts: [{ + id: 'patient.birth-date', + label: 'Birth date', + ruleId: 'direct.leaf.v1', + source: { resourceType: 'Patient', sourcePath: 'Patient', valuePath: 'birthDate', primitive: 'date', populationCount: 3 }, + output: { valueType: 'date', cardinality: 'optional-one', selection: { sourcePath: 'Patient', valueSelector: 'birthDate' } }, + examples: { values: ['1984-03-12'], limited: false }, + }], + }], + }], + diagnostics: [], + }); + const concept = catalog.resources[0].families[0].concepts[0]; + expect(concept.family).toBe('demographics'); + expect(concept.selector).toEqual({ sourcePath: 'Patient', valuePath: 'birthDate' }); + expect(concept.column.logicalType).toBe('date'); + expect(concept.population?.recordCount).toBe(3); + expect(concept.examples?.values).toEqual(['1984-03-12']); + }); +}); diff --git a/packages/core/src/features/explorerBuilder/types.ts b/packages/core/src/features/explorerBuilder/types.ts new file mode 100644 index 00000000..bc45d255 --- /dev/null +++ b/packages/core/src/features/explorerBuilder/types.ts @@ -0,0 +1,296 @@ +import type { JSONValue } from '../../types'; +import type { LoomColumn } from '../loom'; + +export interface BuilderDiagnostic { + readonly severity: 'error' | 'warning'; + readonly code: string; + readonly configPath?: string; + readonly message: string; + readonly retryable?: boolean; + readonly requestId?: string; + readonly details?: Readonly>; +} + +export interface BuilderProject { + readonly organization: string; + readonly project: string; +} + +export type RecipeAuthoringDocument = Readonly>; +export type ExplorerAuthoringDocument = Readonly>; + +/** + * Researcher-facing concept metadata returned by Loom's semantic catalog. + * These fields intentionally remain open strings: deployments can add source + * systems, families, rules, and logical types without a frontend release. + */ +export interface SemanticConceptSource { + readonly system?: string; + readonly standardVersion?: string; + readonly kind?: string; + readonly resourceType?: string; + readonly keyPaths?: ReadonlyArray; + readonly valuePaths?: ReadonlyArray; + readonly logicalType?: string; + readonly terminology?: Readonly>; + readonly [key: string]: unknown; +} + +export interface SemanticConceptSelector { + readonly sourcePath?: string; + readonly valuePath?: string; + readonly [key: string]: unknown; +} + +export interface SemanticConceptColumn { + readonly name: string; + readonly logicalType?: string; + readonly nullable?: boolean; + readonly repeated?: boolean; + readonly filterable?: boolean; + readonly sortable?: boolean; + readonly aggregatable?: boolean; + readonly [key: string]: unknown; +} + +export interface SemanticConceptPopulation { + readonly recordCount?: number; + readonly fraction?: number; + readonly [key: string]: unknown; +} + +export interface SemanticConceptExamples { + readonly values?: ReadonlyArray; + readonly suppressed?: boolean; + readonly reason?: string; + readonly [key: string]: unknown; +} + +export interface SemanticConceptRepetition { + readonly shape?: string; + readonly rowExpansion?: string; + readonly maxItemsObserved?: number; + readonly [key: string]: unknown; +} + +export interface SemanticConcept { + readonly id: string; + readonly label: string; + readonly family?: string; + readonly ruleId: string; + readonly description?: string; + readonly source?: SemanticConceptSource; + readonly selector?: SemanticConceptSelector; + readonly column: SemanticConceptColumn; + readonly population?: SemanticConceptPopulation; + readonly examples?: SemanticConceptExamples; + readonly repetition?: SemanticConceptRepetition; + readonly [key: string]: unknown; +} + +export interface SemanticConceptFamily { + readonly id: string; + readonly label?: string; + readonly concepts: ReadonlyArray; + readonly [key: string]: unknown; +} + +export interface SemanticConceptResource { + readonly resourceType: string; + readonly label?: string; + readonly documentCount?: number; + readonly families: ReadonlyArray; + readonly [key: string]: unknown; +} + +export interface SemanticCatalogCompleteness { + readonly state?: string; + readonly resourceLimit?: number; + readonly conceptLimitPerResource?: number; + readonly returnedResourceCount?: number; + readonly returnedConceptCount?: number; + readonly [key: string]: unknown; +} + +export interface SemanticCatalogDiagnostic { + readonly severity: 'error' | 'warning' | 'info'; + readonly code: string; + readonly message: string; + readonly retryable?: boolean; + readonly details?: Readonly>; +} + +export interface SemanticConceptCatalog { + readonly schemaVersion: number; + readonly catalogId?: string; + readonly project?: Readonly<{ organization?: string; project?: string }>; + readonly source?: Readonly>; + readonly completeness?: SemanticCatalogCompleteness; + readonly resources: ReadonlyArray; + readonly diagnostics: ReadonlyArray; +} + +/** A single, value-bearing column offered by Loom for one authored recipe family. */ +export interface RecipeColumnCandidate { + readonly id: string; + readonly output: string; + readonly nodePath: string; + readonly familyId: string; + readonly familyKind: 'FIELD' | 'CATALOG_PROJECTION' | 'DYNAMIC' | 'EXTENSION' | 'PIVOT' | string; + readonly familyName: string; + readonly patchPath: string; + readonly rawKey: string; + /** Exact native value to write to the family's columns declaration. */ + readonly selectionKey: string; + readonly rawSystem: string; + readonly rawCode: string; + readonly extensionUrl: string; + readonly publicName: string; + readonly label: string; + readonly valueSelector: string; + readonly valueType: string; + readonly cardinality: string; + readonly population: number; + readonly examples: ReadonlyArray; + readonly selected: boolean; + readonly complete: boolean; + readonly diagnostic: string; + /** Serialized native ExtensionColumnMapping when familyKind is EXTENSION. */ + readonly extensionMapping?: string; +} + +export interface RecipeColumnCandidateCompleteness { + readonly complete: boolean; + readonly totalCount: number; + readonly returnedCount: number; + readonly blockingDiagnosticCount: number; +} + +export interface RecipeColumnCandidateConnection { + readonly nodes: ReadonlyArray; + readonly pageInfo: Readonly<{ hasNextPage: boolean; endCursor?: string | null }>; + readonly completeness: RecipeColumnCandidateCompleteness; + readonly diagnostics: ReadonlyArray; +} + +export interface PublishedOutputRef { + readonly output: string; + readonly materializationId: string; + readonly recipeName: string; + readonly translationVersion: string; + readonly recipeDigest: string; + readonly resolvedSchemaDigest: string; + readonly sourceGeneration: string; + readonly columns?: ReadonlyArray; +} + +export interface RecipeDraftValidation { + readonly recipeDigest?: string; + readonly resolvedSchemaDigest?: string; + readonly sourceGeneration?: string; + readonly outputs: ReadonlyArray<{ + readonly name: string; + readonly rootResourceType: string; + readonly rowGrain: string; + readonly columns: ReadonlyArray; + }>; + readonly diagnostics: ReadonlyArray; +} + +export interface RecipeDraftPreview { + readonly validation: RecipeDraftValidation; + readonly output: string; + readonly columns: ReadonlyArray; + readonly rows: ReadonlyArray>>; + readonly rowCount: number; +} + +export interface ProjectRecipeDraft { + readonly project: string; + readonly source: 'platform-default' | 'project-draft'; + readonly draftVersion: number; + readonly document: RecipeAuthoringDocument; + readonly authoringDigest: string; + readonly baseRevisionId?: string | null; + readonly updatedBy?: string; + readonly updatedAt?: string; +} + +export interface ProjectRecipeRevision { + readonly id: string; + readonly project: string; + readonly revisionNumber: number; + readonly recipeName: string; + readonly translationVersion: string; + readonly canonicalDocument?: RecipeAuthoringDocument; + readonly authoringDigest: string; + readonly recipeDigest: string; + readonly resolvedSchemaDigest: string; + readonly sourceGeneration: string; + readonly status: 'VALIDATING' | 'MATERIALIZING' | 'READY' | 'FAILED'; + readonly outputs: ReadonlyArray; + readonly diagnostics: ReadonlyArray; + readonly createdBy?: string; + readonly createdAt: string; + readonly readyAt?: string | null; +} + +export interface ExplorerBuilderState { + readonly projectId: string; + readonly configId: string; + readonly title: string; + readonly draftContent: ExplorerAuthoringDocument; + readonly draftVersion: number; + readonly baseRevisionId?: string | null; + readonly activeReleaseId?: string | null; + readonly updatedBy?: string; + readonly updatedAt?: string; +} + +export interface ExplorerConfigRevision { + readonly id: string; + readonly projectId: string; + readonly configId: string; + readonly revisionNumber: number; + readonly content: ExplorerAuthoringDocument; + readonly contentDigest: string; + readonly loomRecipeRevisionId: string; + readonly publishedOutputs: ReadonlyArray; + readonly status: 'VALID' | 'VALID_WITH_OMISSIONS' | 'INVALID'; + readonly diagnostics: ReadonlyArray; + readonly createdBy?: string; + readonly createdAt: string; +} + +export interface ExplorerRelease { + readonly releaseId: string; + readonly explorerRevisionId: string; + readonly shareUrl: string; +} + +export interface ResolvedExplorerRelease { + readonly status: 'VALID' | 'VALID_WITH_OMISSIONS' | 'UNAVAILABLE'; + readonly releaseId: string; + readonly configRevisionId: string; + readonly project: BuilderProject; + readonly recipeRevisionId: string; + readonly recipeName: string; + readonly translationVersion: string; + readonly recipeDigest: string; + readonly sourceGeneration: string; + readonly outputs: Readonly>; + readonly config: ExplorerAuthoringDocument; + readonly errors: ReadonlyArray; + readonly warnings: ReadonlyArray; + readonly acknowledgedOmissions: ReadonlyArray; +} + +export interface BuilderApiError { + readonly status: number | string; + readonly requestId?: string; + readonly retryable: boolean; + readonly diagnostics: ReadonlyArray; + readonly currentVersion?: number; + readonly currentDigest?: string; + readonly updatedAt?: string; +} diff --git a/packages/core/src/features/loom/filters.ts b/packages/core/src/features/loom/filters.ts new file mode 100644 index 00000000..2fdcc529 --- /dev/null +++ b/packages/core/src/features/loom/filters.ts @@ -0,0 +1,96 @@ +import type { + Excludes, + ExcludeIfAny, + FilterSet, + Includes, + Intersection, + Operation, +} from '../filters'; +import type { LoomFilter } from './types'; + +const scalarFilter = ( + column: string, + op: string, + value: unknown, +): LoomFilter => ({ column, op, value }); + +const convertOperation = ( + column: string, + operation: Operation, +): Array => { + switch (operation.operator) { + case '=': + return [scalarFilter(column, 'EQ', operation.operand)]; + case '!=': + return [scalarFilter(column, 'NEQ', operation.operand)]; + case '<': + case '<=': + case '>': + case '>=': + return [scalarFilter(column, operation.operator, operation.operand)]; + case 'in': + case 'includes': + return [scalarFilter(column, 'IN', (operation as Includes).operands)]; + case 'excludes': + return [scalarFilter(column, 'NOT_IN', (operation as Excludes).operands)]; + case 'excludeifany': + return [ + scalarFilter(column, 'NOT_IN', (operation as ExcludeIfAny).operands), + ]; + case 'missing': + return [scalarFilter(column, 'IS_NULL', null)]; + case 'exists': + return [scalarFilter(column, 'IS_NOT_NULL', null)]; + case 'and': + return (operation as Intersection).operands.flatMap((child) => + convertOperation(column, child), + ); + case 'or': + throw new Error( + `Unsupported Loom filter union for column ${column}; use a supported scalar or IN filter`, + ); + case 'nested': { + let nestedOperation: Operation = operation.operand; + const nestedPath = [operation.path]; + while (nestedOperation.operator === 'nested') { + nestedPath.push(nestedOperation.path); + nestedOperation = nestedOperation.operand; + } + const leafField = 'field' in nestedOperation ? nestedOperation.field : ''; + const flattenedColumn = column.includes('.') + ? column + : [...nestedPath, leafField].filter(Boolean).join('.'); + return convertOperation(flattenedColumn, nestedOperation); + } + default: + throw new Error('Unsupported Loom filter operation'); + } +}; + +export const convertFilterSetToLoomFilters = ( + filters?: FilterSet, +): Array => { + if (!filters) return []; + if (filters.mode === 'or') { + throw new Error( + 'Unsupported Loom filter union at the filter-set level; use a supported scalar or IN filter', + ); + } + return Object.entries(filters.root).flatMap(([column, operation]) => + convertOperation(column, operation), + ); +}; + +export const assertLoomFilterable = ( + filters: ReadonlyArray, + columns: ReadonlyArray<{ name: string; filterable: boolean }>, +) => { + const columnMap = new Map(columns.map((column) => [column.name, column])); + for (const filter of filters) { + const column = columnMap.get(filter.column); + if (!column) throw new Error(`Unknown Loom filter column: ${filter.column}`); + if (!column.filterable) { + throw new Error(`Loom column is not filterable: ${filter.column}`); + } + } +}; diff --git a/packages/core/src/features/loom/index.ts b/packages/core/src/features/loom/index.ts new file mode 100644 index 00000000..dcdcc1cc --- /dev/null +++ b/packages/core/src/features/loom/index.ts @@ -0,0 +1,6 @@ +export * from './loomApi'; +export * from './loomSlice'; +export * from './loomDownload'; +export * from './filters'; +export * from './processing'; +export * from './types'; diff --git a/packages/core/src/features/loom/loomApi.ts b/packages/core/src/features/loom/loomApi.ts new file mode 100644 index 00000000..ec66ece1 --- /dev/null +++ b/packages/core/src/features/loom/loomApi.ts @@ -0,0 +1,368 @@ +import type { BaseQueryFn } from '@reduxjs/toolkit/query'; +import { createApi } from '@reduxjs/toolkit/query/react'; +import { getCookie } from 'cookies-next'; +import { GEN3_LOOM_API } from '../../constants'; +import { selectCSRFToken } from '../user/userSliceRTK'; +import { LoomGraphQLRequestError } from './types'; +import type { CoreState } from '../../reducers'; +import type { + LoomApiError, + LoomGraphQLResponse, + LoomGraphQLError, + LoomQueryArgs, + LoomRequestMeta, + LoomRequestOptions, +} from './types'; + +type LoomRequestError = Error & + Partial> & { + readonly requestId?: string; + readonly retryable?: boolean; + readonly fieldPath?: string | null; + readonly httpStatus?: number; + readonly meta?: LoomRequestMeta; + }; + +interface GraphQLExecutionResult { + readonly data: T; + readonly meta: LoomRequestMeta; +} + +const authSummary = (headers: Record) => { + const cookie = headers.Cookie ?? headers.cookie; + const authorization = headers.Authorization ?? headers.authorization; + return { + hasCookie: Boolean(cookie), + cookieNames: cookie + ?.split(';') + .map((part) => part.trim().split('=', 1)[0]) + .filter(Boolean), + hasAuthorization: Boolean(authorization), + authorizationScheme: authorization?.split(' ', 1)[0], + }; +}; + +export const fetchLoomResponse = async ( + endpoint: string, + init: RequestInit = {}, +): Promise => { + const headers = new Headers(init.headers); + if (!headers.has('Accept')) headers.set('Accept', 'application/json'); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + if (process.env.NODE_ENV === 'development' && !headers.has('Authorization')) { + const accessToken = getCookie('credentials_token'); + if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`); + } + + console.info('[Loom] Request', { + endpoint, + method: init.method ?? 'GET', + ...(typeof window === 'undefined' + ? { + auth: authSummary({ + Cookie: headers.get('Cookie') ?? '', + Authorization: headers.get('Authorization') ?? '', + }), + } + : {}), + }); + + const response = await fetch(endpoint, { + ...init, + credentials: 'include', + headers, + }); + + console.info('[Loom] Response', { + status: response.status, + requestId: response.headers.get('x-request-id') ?? 'none', + }); + return response; +}; + +const getResponseRequestId = (response: Response): string | undefined => + response.headers.get('x-request-id') ?? + response.headers.get('x-requestid') ?? + response.headers.get('request-id') ?? + undefined; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const isAbortError = (error: unknown): boolean => + error instanceof Error && error.name === 'AbortError'; + +const isRetryableHttpStatus = (status: number): boolean => + status === 408 || status === 425 || status === 429 || status >= 500; + +const getFirstGraphQLError = ( + errors: unknown, +): LoomGraphQLError | undefined => { + if (!Array.isArray(errors)) return undefined; + const error = errors[0]; + if (!isRecord(error)) return undefined; + + return { + message: + typeof error.message === 'string' + ? error.message + : 'GraphQL request failed', + locations: Array.isArray(error.locations) + ? (error.locations as LoomGraphQLError['locations']) + : undefined, + path: Array.isArray(error.path) + ? (error.path as LoomGraphQLError['path']) + : undefined, + extensions: isRecord(error.extensions) + ? (error.extensions as LoomGraphQLError['extensions']) + : undefined, + }; +}; + +const getGraphQLErrorRequestId = ( + error: LoomGraphQLError | undefined, + responseRequestId: string | undefined, +): string | undefined => + typeof error?.extensions?.requestId === 'string' + ? error.extensions.requestId + : responseRequestId; + +const getGraphQLErrorFieldPath = ( + error: LoomGraphQLError | undefined, +): string | null | undefined => { + const fieldPath = error?.extensions?.fieldPath; + if (typeof fieldPath === 'string' || fieldPath === null) return fieldPath; + return error?.path?.length ? error.path.join('.') : undefined; +}; + +const createGraphQLError = ({ + response, + endpoint, + payload, + responseRequestId, +}: { + response: Response; + endpoint: string; + payload?: unknown; + responseRequestId?: string; +}): LoomGraphQLRequestError => { + const firstError = getFirstGraphQLError( + isRecord(payload) ? payload.errors : undefined, + ); + const extensions = firstError?.extensions; + const requestId = getGraphQLErrorRequestId(firstError, responseRequestId); + const status = response.ok ? 'CUSTOM_ERROR' : response.status; + const retryable = + typeof extensions?.retryable === 'boolean' + ? extensions.retryable + : isRetryableHttpStatus(response.status); + + return new LoomGraphQLRequestError({ + status, + httpStatus: response.status, + message: + firstError?.message ?? + `Loom GraphQL request failed with HTTP ${response.status}`, + data: payload, + code: typeof extensions?.code === 'string' ? extensions.code : undefined, + requestId, + retryable, + fieldPath: getGraphQLErrorFieldPath(firstError), + meta: { + endpoint, + status: response.status, + requestId, + }, + }); +}; + +const executeGraphQL = async ( + { query, variables }: LoomQueryArgs, + options: LoomRequestOptions = {}, +): Promise> => { + const endpoint = options.endpoint ?? `${GEN3_LOOM_API}/graphql/flat`; + let response: Response; + + try { + response = await fetchLoomResponse(endpoint, { + method: 'POST', + headers: options.headers, + body: JSON.stringify({ query, variables }), + signal: options.signal, + cache: 'no-store', + }); + } catch (error: unknown) { + if (isAbortError(error)) throw error; + console.error('[Loom] Transport failed', { + endpoint, + error: error instanceof Error ? error.message : String(error), + }); + throw new LoomGraphQLRequestError({ + status: 'FETCH_ERROR', + message: error instanceof Error ? error.message : String(error), + retryable: true, + meta: { endpoint }, + cause: error, + }); + } + + const responseRequestId = getResponseRequestId(response); + const responseText = await response.text(); + let parsedPayload: unknown; + try { + parsedPayload = JSON.parse(responseText) as unknown; + } catch { + throw new LoomGraphQLRequestError({ + status: response.status, + httpStatus: response.status, + message: `Loom GraphQL returned non-JSON HTTP ${response.status}`, + data: responseText, + requestId: responseRequestId, + retryable: isRetryableHttpStatus(response.status), + meta: { + endpoint, + status: response.status, + requestId: responseRequestId, + }, + }); + } + + if (!isRecord(parsedPayload)) { + throw new LoomGraphQLRequestError({ + status: response.ok ? 'CUSTOM_ERROR' : response.status, + httpStatus: response.status, + message: 'Loom GraphQL response did not contain a valid payload', + data: parsedPayload, + requestId: responseRequestId, + retryable: isRetryableHttpStatus(response.status), + meta: { + endpoint, + status: response.status, + requestId: responseRequestId, + }, + }); + } + + const payload = parsedPayload as LoomGraphQLResponse; + + if (payload.errors?.length) { + console.error('[Loom] GraphQL errors', payload.errors); + } + + if (!response.ok || payload.errors?.length) { + throw createGraphQLError({ + response, + endpoint, + payload, + responseRequestId, + }); + } + if (payload.data === undefined) { + throw new LoomGraphQLRequestError({ + status: 'CUSTOM_ERROR', + httpStatus: response.status, + message: 'Loom GraphQL response did not contain data', + data: payload, + requestId: responseRequestId, + meta: { + endpoint, + status: response.status, + requestId: responseRequestId, + }, + }); + } + + return { + data: payload.data, + meta: { + endpoint, + status: response.status, + requestId: responseRequestId, + }, + }; +}; + +export const fetchGraphQL = async ( + request: LoomQueryArgs, + options: LoomRequestOptions = {}, +): Promise => (await executeGraphQL(request, options)).data; + +export const fetchLoomGraphQL = async ( + request: LoomQueryArgs, + options: LoomRequestOptions = {}, +): Promise => fetchGraphQL(request, options); + +export const loomBaseQuery: BaseQueryFn< + LoomQueryArgs, + unknown, + LoomApiError, + // RTK uses {} as the default extra-options type for existing endpoints. + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + {}, + LoomRequestMeta +> = async ({ query, variables }, api) => { + const csrfToken = selectCSRFToken(api.getState() as CoreState); + const endpoint = `${GEN3_LOOM_API}/graphql/flat`; + try { + const result = await executeGraphQL( + { query, variables }, + { + endpoint, + signal: api.signal, + headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : undefined, + }, + ); + return { + data: result.data, + meta: result.meta, + }; + } catch (error: unknown) { + const requestError = error as LoomRequestError; + const errorStatus = requestError.status ?? 'FETCH_ERROR'; + const retryable = requestError.retryable ?? !isAbortError(error); + const meta = requestError.meta ?? { + endpoint, + status: requestError.httpStatus, + requestId: requestError.requestId, + }; + console.error('[Loom] Query failed', { + operation: + query.match(/\b(?:query|mutation)\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? + 'anonymous', + status: errorStatus, + httpStatus: requestError.httpStatus, + code: requestError.code, + requestId: requestError.requestId, + retryable, + fieldPath: requestError.fieldPath, + error: error instanceof Error ? error.message : String(error), + data: requestError.data, + }); + return { + error: { + status: errorStatus, + httpStatus: requestError.httpStatus, + data: requestError.data, + code: requestError.code, + requestId: requestError.requestId, + retryable, + fieldPath: requestError.fieldPath, + error: error instanceof Error ? error.message : String(error), + }, + meta, + }; + } +}; + +export const loomApi = createApi({ + reducerPath: 'loom', + baseQuery: loomBaseQuery, + tagTypes: ['LOOM_DATASET', 'LOOM_ROWS', 'LOOM_AGGREGATE'], + endpoints: () => ({}), +}); + +export const loomApiSliceMiddleware = loomApi.middleware; +export const loomApiSliceReducerPath = loomApi.reducerPath; +export const loomApiReducer = loomApi.reducer; diff --git a/packages/core/src/features/loom/loomDownload.ts b/packages/core/src/features/loom/loomDownload.ts new file mode 100644 index 00000000..d970cf17 --- /dev/null +++ b/packages/core/src/features/loom/loomDownload.ts @@ -0,0 +1,138 @@ +import { GEN3_LOOM_API } from '../../constants'; +import { coreStore } from '../../store'; +import { selectCSRFToken } from '../user'; +import { isJSONObject, JSONObject } from '../../types'; +import { convertFilterSetToLoomFilters } from './filters'; +import { fetchLoomResponse } from './loomApi'; +import type { FilterSet } from '../filters'; +import type { LoomDataType, LoomDatasetSelector, LoomSort } from './types'; +import { validateLoomDatasetSelector } from './types'; + +export interface LoomDownloadParams { + readonly type?: LoomDataType; + readonly selector?: LoomDatasetSelector; + readonly fields: ReadonlyArray; + readonly filter?: FilterSet; + readonly sort?: unknown; + readonly format: 'json' | 'csv' | 'tsv' | 'jsonl'; + readonly filename?: string; +} + +export interface DownloadFromLoomParams { + readonly parameters: LoomDownloadParams; + readonly onStart?: () => void; + readonly onDone?: (blob: Blob) => void; + readonly onError?: (error: Error) => void; + readonly onAbort?: () => void; + readonly signal?: AbortSignal; +} + +const normalizeSort = (sort: unknown): LoomSort | undefined => { + if (!sort) return undefined; + if (Array.isArray(sort)) { + const first = sort[0]; + if (typeof first === 'object' && first !== null) { + const [column, direction] = + Object.entries(first as Record)[0] ?? []; + if (column) return { column, desc: direction === 'desc' }; + } + return undefined; + } + if (typeof sort === 'object') { + const candidate = sort as Partial; + if (typeof candidate.column === 'string') return candidate as LoomSort; + } + return undefined; +}; + +export const buildLoomDownloadRequest = (parameters: LoomDownloadParams) => { + if (!parameters.type && !parameters.selector) { + throw new Error('A Loom download requires a dataset identity.'); + } + if (parameters.selector) { + const diagnostic = validateLoomDatasetSelector(parameters.selector); + if (diagnostic) throw new Error(diagnostic.message); + } + return { + ...(parameters.selector?.materializationId + ? { materializationId: parameters.selector.materializationId } + : parameters.selector + ? { + selector: { + recipe: parameters.selector.recipe, + translationVersion: parameters.selector.translationVersion, + output: parameters.selector.output, + }, + } + : { dataType: parameters.type }), + columns: [...parameters.fields], + filters: convertFilterSetToLoomFilters(parameters.filter), + sort: normalizeSort(parameters.sort), + format: parameters.format.toUpperCase(), + filename: parameters.filename, + }; +}; + +const fetchLoomExport = async ( + parameters: LoomDownloadParams, + signal?: AbortSignal, +): Promise => { + const csrfToken = selectCSRFToken(coreStore.getState()); + return fetchLoomResponse(`${GEN3_LOOM_API}/api/v1/dataframe/export`, { + method: 'POST', + headers: { + Accept: 'application/octet-stream', + 'Content-Type': 'application/json', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}), + }, + body: JSON.stringify(buildLoomDownloadRequest(parameters)), + signal, + }); +}; + +export const downloadFromLoomToBlob = async ({ + parameters, + onStart = () => null, + onDone = (_: Blob) => null, + onError = (_: Error) => null, + onAbort = () => null, + signal, +}: DownloadFromLoomParams) => { + onStart(); + try { + const response = await fetchLoomExport(parameters, signal); + if (!response.ok) { + throw new Error((await response.text()) || response.statusText); + } + onDone(await response.blob()); + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') { + onAbort(); + return; + } + onError(error instanceof Error ? error : new Error(String(error))); + } +}; + +export const downloadJSONDataFromLoom = async ({ + parameters, + onAbort = () => null, + signal, +}: Omit): Promise< + JSONObject[] +> => { + try { + const response = await fetchLoomExport( + { ...parameters, format: 'json' }, + signal, + ); + if (!response.ok) { + throw new Error((await response.text()) || response.statusText); + } + const value = await response.json(); + return Array.isArray(value) ? value.filter(isJSONObject) : []; + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') onAbort(); + throw error instanceof Error ? error : new Error(String(error)); + } +}; diff --git a/packages/core/src/features/loom/loomSlice.ts b/packages/core/src/features/loom/loomSlice.ts new file mode 100644 index 00000000..244c5e4c --- /dev/null +++ b/packages/core/src/features/loom/loomSlice.ts @@ -0,0 +1,294 @@ +import type { AggregationsData } from '../../types'; +import { loomApi } from './loomApi'; +import { shapeLoomRows, toHistogramKey } from './processing'; +import type { + LoomAggregateRequest, + LoomAggregateResponse, + LoomAggregationsRequest, + LoomDataset, + LoomRowsRequest, + LoomRowsResponse, + LoomQueryArgs, + LoomDatasetIdentity, +} from './types'; +import { loomDatasetIdentityKey, validateLoomDatasetSelector } from './types'; + +const datasetFields = ` + id name revision state rowCount createdAt readyAt error + columns { name clickhouseType logicalType nullable repeated filterable sortable aggregatable } +`; + +const aggregateFields = ` + materialization { ${datasetFields} } + columns + rows +`; + +const rowsFields = ` + materialization { ${datasetFields} } + columns + rows + totalCount + pageInfo { hasNextPage endCursor } +`; + +export const buildLoomDatasetQuery = (dataType: string): LoomQueryArgs => ({ + query: `query LoomDataset($dataType: String!) { dataframeDataset(input: { dataType: $dataType }) { ${datasetFields} } }`, + variables: { dataType }, +}); + +const buildLoomIdentityInput = (identity: LoomDatasetIdentity) => { + if (identity.materializationId) return { materializationId: identity.materializationId }; + if (!identity.selector) return { dataType: identity.dataType }; + const diagnostic = validateLoomDatasetSelector(identity.selector); + if (diagnostic) throw new Error(diagnostic.message); + if (identity.selector.materializationId) { + return { materializationId: identity.selector.materializationId }; + } + return { + selector: { + recipe: identity.selector.recipe, + translationVersion: identity.selector.translationVersion, + output: identity.selector.output, + }, + }; +}; + +export const buildLoomDatasetSelectorQuery = ( + selector: LoomDatasetIdentity, +): LoomQueryArgs => ({ + query: `query LoomDataset($input: DataframeDatasetInput!) { dataframeDataset(input: $input) { ${datasetFields} } }`, + variables: { input: buildLoomIdentityInput(selector) }, +}); + +export const buildLoomDatasetColumnsQuery = ( + dataTypes: ReadonlyArray, +): LoomQueryArgs => ({ + query: `query LoomDatasetColumns { ${dataTypes + .map( + (dataType, index) => + `d${index}: dataframeDataset(input: { dataType: ${JSON.stringify(dataType)} }) { name columns { name } }`, + ) + .join(' ')} }`, +}); + +export const buildLoomRowsQuery = (input: LoomRowsRequest): LoomQueryArgs => ({ + query: `query LoomRows($input: DataframeRowsInput!) { dataframeRows(input: $input) { ${rowsFields} } }`, + variables: { + input: { + ...buildLoomIdentityInput(input), + columns: input.columns ? [...input.columns] : undefined, + filters: input.filters ? [...input.filters] : undefined, + sort: input.sort, + first: input.first, + after: input.after, + }, + }, +}); + +export const buildLoomAggregateQuery = ( + input: LoomAggregateRequest, +): LoomQueryArgs => ({ + query: `query LoomAggregate($input: DataframeAggregateInput!) { dataframeAggregate(input: $input) { ${aggregateFields} } }`, + variables: { + input: { + ...buildLoomIdentityInput(input), + groupBy: input.groupBy ? [...input.groupBy] : undefined, + filters: input.filters ? [...input.filters] : undefined, + operation: input.operation, + column: input.column, + }, + }, +}); + +export const buildLoomCountQuery = ( + input: LoomAggregateRequest, +): LoomQueryArgs => ({ + query: `query LoomCount($input: DataframeAggregateInput!) { dataframeAggregate(input: $input) { columns rows } }`, + variables: { + input: { + ...buildLoomIdentityInput(input), + filters: input.filters ? [...input.filters] : undefined, + operation: 'COUNT', + }, + }, +}); + +export const buildLoomAggregationsQuery = ( + input: LoomAggregationsRequest, +): LoomQueryArgs => { + if (input.selector) { + const variables: Record = {}; + const selections = input.fields + .map((field, index) => { + variables[`input${index}`] = { + ...buildLoomIdentityInput(input), + groupBy: [field], + filters: input.filters ? [...input.filters] : [], + operation: 'COUNT', + column: field, + }; + return `a${index}: dataframeAggregate(input: $input${index}) { columns rows }`; + }) + .join('\n'); + const declarations = input.fields + .map((_, index) => `$input${index}: DataframeAggregateInput!`) + .join(', '); + return { + query: `query LoomAggregations(${declarations}) { ${selections} }`, + variables, + }; + } + const selections = input.fields + .map( + (field, index) => + `a${index}: dataframeAggregate(input: { dataType: $dataType, groupBy: [${JSON.stringify(field)}], filters: $filters, operation: "COUNT", column: ${JSON.stringify(field)} }) { columns rows }`, + ) + .join('\n'); + return { + query: `query LoomAggregations($dataType: String!, $filters: [DataframeFilterInput!]) { ${selections} }`, + variables: { + dataType: input.dataType, + filters: input.filters ? [...input.filters] : [], + }, + }; +}; + +const normalizeRowsResponse = ( + response: Omit & { rows: unknown }, +): LoomRowsResponse => ({ + ...response, + rows: shapeLoomRows(response.rows, response.columns), +}); + +const normalizeAggregateResponse = ( + response: Omit & { rows: unknown }, +): LoomAggregateResponse => ({ + ...response, + rows: shapeLoomRows(response.rows, response.columns), +}); + +export const normalizeLoomRowsGraphQLResponse = (response: { + dataframeRows: Omit & { rows: unknown }; +}): LoomRowsResponse => normalizeRowsResponse(response.dataframeRows); + +export const normalizeLoomAggregateGraphQLResponse = (response: { + dataframeAggregate: Omit & { rows: unknown }; +}): LoomAggregateResponse => + normalizeAggregateResponse(response.dataframeAggregate); + +export const loomTags = loomApi.enhanceEndpoints({ + addTagTypes: ['LOOM_DATASET', 'LOOM_ROWS', 'LOOM_AGGREGATE'], +}); + +export const loomSlice = loomTags.injectEndpoints({ + endpoints: (builder) => ({ + getLoomDatasets: builder.query, void>({ + query: () => ({ + query: `query LoomDatasets { dataframeDatasets { ${datasetFields} } }`, + }), + transformResponse: (response: { + dataframeDatasets: Array; + }) => response.dataframeDatasets ?? [], + providesTags: ['LOOM_DATASET'], + }), + getLoomDataset: builder.query({ + query: buildLoomDatasetQuery, + transformResponse: (response: { dataframeDataset: LoomDataset | null }) => + response.dataframeDataset, + providesTags: (_result, _error, dataType) => [ + { type: 'LOOM_DATASET', id: dataType }, + ], + }), + getLoomDatasetBySelector: builder.query< + LoomDataset | null, + LoomDatasetIdentity + >({ + query: buildLoomDatasetSelectorQuery, + transformResponse: (response: { dataframeDataset: LoomDataset | null }) => + response.dataframeDataset, + providesTags: (_result, _error, identity) => [ + { type: 'LOOM_DATASET', id: loomDatasetIdentityKey(identity) }, + ], + }), + getLoomRows: builder.query({ + query: buildLoomRowsQuery, + transformResponse: normalizeLoomRowsGraphQLResponse, + providesTags: (_result, _error, input) => [ + { type: 'LOOM_ROWS', id: loomDatasetIdentityKey(input) }, + ], + }), + getLoomAggregate: builder.query< + LoomAggregateResponse, + LoomAggregateRequest + >({ + query: buildLoomAggregateQuery, + transformResponse: normalizeLoomAggregateGraphQLResponse, + providesTags: (_result, _error, input) => [ + { type: 'LOOM_AGGREGATE', id: loomDatasetIdentityKey(input) }, + ], + }), + getLoomCount: builder.query({ + query: buildLoomCountQuery, + transformResponse: (response: { + dataframeAggregate: { rows: unknown }; + }) => { + const rows = Array.isArray(response.dataframeAggregate?.rows) + ? response.dataframeAggregate.rows + : []; + const row = rows[0]; + if (typeof row === 'number') return row; + if (row && typeof row === 'object') { + const values = Object.values(row as Record); + return Number(values[0]) || 0; + } + return 0; + }, + providesTags: (_result, _error, input) => [ + { type: 'LOOM_AGGREGATE', id: loomDatasetIdentityKey(input) }, + ], + }), + getLoomAggregations: builder.query< + Record, + LoomAggregationsRequest + >({ + query: buildLoomAggregationsQuery, + transformResponse: ( + response: Record, + _meta, + args, + ): AggregationsData => { + const aggregations: AggregationsData = {}; + + args.fields.forEach((field, index) => { + const value = response[`a${index}`]; + const rows = shapeLoomRows(value?.rows, value?.columns ?? []); + aggregations[field] = rows.map((row) => ({ + key: toHistogramKey( + row.key ?? row[field] ?? row[value?.columns?.[0] ?? ''], + ), + count: + Number( + row.doc_count ?? row.count ?? row[value?.columns?.[1] ?? ''], + ) || 0, + })); + }); + + return aggregations; + }, + providesTags: (_result, _error, input) => [ + { type: 'LOOM_AGGREGATE', id: loomDatasetIdentityKey(input) }, + ], + }), + }), +}); + +export const { + useGetLoomDatasetsQuery, + useGetLoomDatasetQuery, + useGetLoomDatasetBySelectorQuery, + useGetLoomRowsQuery, + useGetLoomAggregateQuery, + useGetLoomCountQuery, + useGetLoomAggregationsQuery, +} = loomSlice; diff --git a/packages/core/src/features/loom/processing.ts b/packages/core/src/features/loom/processing.ts new file mode 100644 index 00000000..a6077f7c --- /dev/null +++ b/packages/core/src/features/loom/processing.ts @@ -0,0 +1,95 @@ +import type { AggregationsData, JSONObject, StatsData } from '../../types'; +import type { LoomAggregateResponse, LoomColumn } from './types'; + +const isObject = (value: unknown): value is JSONObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const assignDottedValue = (row: JSONObject, path: string, value: unknown) => { + const parts = path.split('.').filter(Boolean); + if (parts.length < 2) { + row[path] = value as never; + return; + } + + let cursor = row; + for (const part of parts.slice(0, -1)) { + const existing = cursor[part]; + if (!isObject(existing)) cursor[part] = {}; + cursor = cursor[part] as JSONObject; + } + cursor[parts[parts.length - 1]] = value as never; +}; + +export const shapeLoomRow = ( + row: unknown, + columns: ReadonlyArray, +): JSONObject => { + const source = Array.isArray(row) + ? Object.fromEntries(columns.map((column, index) => [column, row[index]])) + : isObject(row) + ? row + : {}; + const shaped: JSONObject = {}; + Object.entries(source).forEach(([key, value]) => + assignDottedValue(shaped, key, value), + ); + return shaped; +}; + +export const shapeLoomRows = ( + rows: unknown, + columns: ReadonlyArray, +): Array => + Array.isArray(rows) ? rows.map((row) => shapeLoomRow(row, columns)) : []; + +export const columnsToFieldMapping = ( + columns: ReadonlyArray, +): Record => + Object.fromEntries(columns.map((column) => [column.name, column])); + +const numericValue = (value: unknown): number => { + const result = typeof value === 'number' ? value : Number(value); + return Number.isFinite(result) ? result : 0; +}; + +export const toHistogramKey = (value: unknown): string | [number, number] => { + if ( + Array.isArray(value) && + value.length === 2 && + value.every((item) => typeof item === 'number') + ) { + return [value[0], value[1]]; + } + + return typeof value === 'string' ? value : String(value ?? ''); +}; + +export const aggregateToHistogram = ( + response: LoomAggregateResponse, + field: string, +): AggregationsData => { + const rows = response.rows.map((row) => { + const key = row.key ?? row[field] ?? row[response.columns[0]]; + const count = row.doc_count ?? row.count ?? row[response.columns[1]]; + return { key: toHistogramKey(key), count: numericValue(count) }; + }); + return { [field]: rows }; +}; + +export const aggregateToStats = ( + response: LoomAggregateResponse, + field: string, +): StatsData => { + const row = response.rows[0] ?? {}; + return { + [field]: [ + { + count: numericValue(row.count), + min: numericValue(row.min), + max: numericValue(row.max), + avg: numericValue(row.avg), + sum: numericValue(row.sum), + }, + ], + }; +}; diff --git a/packages/core/src/features/loom/tests/loomApi.unit.test.ts b/packages/core/src/features/loom/tests/loomApi.unit.test.ts new file mode 100644 index 00000000..1a5da659 --- /dev/null +++ b/packages/core/src/features/loom/tests/loomApi.unit.test.ts @@ -0,0 +1,269 @@ +import { fetchGraphQL, fetchLoomGraphQL, loomBaseQuery } from '../loomApi'; +import { isLoomGraphQLRequestError, LoomGraphQLRequestError } from '../types'; + +const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json', ...init.headers }, + ...init, + }); + +describe('Loom GraphQL transport', () => { + const originalFetch = global.fetch; + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock as typeof global.fetch; + jest.spyOn(console, 'info').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('fetches generic GraphQL from the configured endpoint and forwards the signal', async () => { + const controller = new AbortController(); + fetchMock.mockResolvedValue( + jsonResponse({ data: { viewer: { id: 'user-1' } } }), + ); + + await expect( + fetchGraphQL<{ viewer: { id: string } }>( + { query: 'query Viewer { viewer { id } }' }, + { + endpoint: 'https://loom.example/graphql', + signal: controller.signal, + }, + ), + ).resolves.toEqual({ viewer: { id: 'user-1' } }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://loom.example/graphql', + expect.objectContaining({ + method: 'POST', + signal: controller.signal, + credentials: 'include', + }), + ); + }); + + it('keeps fetchLoomGraphQL as a compatible wrapper', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { ok: true } })); + + await expect( + fetchLoomGraphQL<{ ok: boolean }>( + { query: 'query Health { ok }' }, + { endpoint: 'https://loom.example/graphql/flat' }, + ), + ).resolves.toEqual({ ok: true }); + }); + + it('exposes HTTP GraphQL errors with response and extension metadata', async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { + errors: [ + { + message: 'the dataframe backend is temporarily unavailable', + path: ['a6'], + extensions: { + code: 'BACKEND_UNAVAILABLE', + requestId: 'graphql-request-1', + retryable: true, + fieldPath: 'Patient.project_id', + }, + }, + ], + }, + { + status: 503, + headers: { 'x-request-id': 'response-request-1' }, + }, + ), + ); + + const result = fetchGraphQL({ query: 'query Broken { a6 }' }); + await expect(result).rejects.toBeInstanceOf(LoomGraphQLRequestError); + await expect(result).rejects.toMatchObject({ + status: 503, + httpStatus: 503, + code: 'BACKEND_UNAVAILABLE', + requestId: 'graphql-request-1', + retryable: true, + fieldPath: 'Patient.project_id', + }); + + try { + await result; + } catch (error: unknown) { + expect(isLoomGraphQLRequestError(error)).toBe(true); + expect((error as LoomGraphQLRequestError).data).toEqual( + expect.objectContaining({ errors: expect.any(Array) }), + ); + expect((error as LoomGraphQLRequestError).meta).toEqual({ + endpoint: 'https://gen3.localhost.io/loom/graphql/flat', + status: 503, + requestId: 'graphql-request-1', + }); + } + }); + + it('treats HTTP-200 GraphQL errors as custom errors', async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { + data: null, + errors: [ + { + message: 'the dataframe backend is temporarily unavailable', + path: ['a6'], + extensions: { + code: 'BACKEND_UNAVAILABLE', + retryable: true, + }, + }, + ], + }, + { headers: { 'x-request-id': 'header-request-1' } }, + ), + ); + + await expect( + fetchGraphQL({ query: 'query Broken { a6 }' }), + ).rejects.toMatchObject({ + status: 'CUSTOM_ERROR', + httpStatus: 200, + code: 'BACKEND_UNAVAILABLE', + requestId: 'header-request-1', + retryable: true, + fieldPath: 'a6', + }); + }); + + it('reports non-JSON responses with HTTP metadata', async () => { + fetchMock.mockResolvedValue( + new Response('upstream gateway failure', { + status: 502, + headers: { 'x-request-id': 'non-json-request-1' }, + }), + ); + + await expect( + fetchGraphQL({ query: 'query Broken { a0 }' }), + ).rejects.toMatchObject({ + status: 502, + httpStatus: 502, + requestId: 'non-json-request-1', + retryable: true, + data: 'upstream gateway failure', + }); + }); + + it('reports a successful response that is missing data', async () => { + fetchMock.mockResolvedValue( + jsonResponse( + {}, + { headers: { 'x-request-id': 'missing-data-request-1' } }, + ), + ); + + await expect( + fetchGraphQL({ query: 'query Missing { a0 }' }), + ).rejects.toMatchObject({ + status: 'CUSTOM_ERROR', + httpStatus: 200, + requestId: 'missing-data-request-1', + retryable: false, + message: 'Loom GraphQL response did not contain data', + }); + }); + + it('preserves AbortSignal cancellation instead of converting it to a GraphQL error', async () => { + const controller = new AbortController(); + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + fetchMock.mockRejectedValue(abortError); + + await expect( + fetchGraphQL( + { query: 'query Cancelled { a0 }' }, + { signal: controller.signal }, + ), + ).rejects.toBe(abortError); + }); + + it('reports transport failures without logging request authentication', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed')); + + await expect( + fetchGraphQL( + { query: 'query Broken { a0 }' }, + { + endpoint: 'http://revproxy-service/loom/graphql/flat', + headers: { + Authorization: 'Bearer secret-token', + Cookie: 'access_token=secret-token', + }, + }, + ), + ).rejects.toMatchObject({ + status: 'FETCH_ERROR', + retryable: true, + }); + expect(console.error).toHaveBeenCalledWith('[Loom] Transport failed', { + endpoint: 'http://revproxy-service/loom/graphql/flat', + error: 'fetch failed', + }); + expect( + JSON.stringify((console.error as jest.Mock).mock.calls), + ).not.toContain('secret-token'); + }); + + it('returns request metadata from the RTK base query on success and failure', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: { ok: true } }, + { headers: { 'x-request-id': 'success-1' } }, + ), + ); + + const baseQueryApi = { + signal: new AbortController().signal, + getState: () => ({}), + } as never; + + await expect( + loomBaseQuery({ query: 'query Health { ok }' }, baseQueryApi, {}), + ).resolves.toMatchObject({ + data: { ok: true }, + meta: { + status: 200, + requestId: 'success-1', + }, + }); + + fetchMock.mockResolvedValueOnce( + jsonResponse( + { errors: [{ message: 'unavailable' }] }, + { status: 503, headers: { 'x-request-id': 'failure-1' } }, + ), + ); + + await expect( + loomBaseQuery({ query: 'query Broken { ok }' }, baseQueryApi, {}), + ).resolves.toMatchObject({ + error: { + status: 503, + httpStatus: 503, + requestId: 'failure-1', + }, + meta: { + status: 503, + requestId: 'failure-1', + }, + }); + }); +}); diff --git a/packages/core/src/features/loom/tests/processing.unit.test.ts b/packages/core/src/features/loom/tests/processing.unit.test.ts new file mode 100644 index 00000000..24ba2bbe --- /dev/null +++ b/packages/core/src/features/loom/tests/processing.unit.test.ts @@ -0,0 +1,31 @@ +import { shapeLoomRows, aggregateToHistogram } from '../processing'; + +describe('Loom response adapters', () => { + it('shapes flat dotted columns while preserving null and repeated values', () => { + expect( + shapeLoomRows( + [['r1', null, ['a', 'b']]], + ['subject.id', 'subject.birthDate', 'tags'], + ), + ).toEqual([ + { + subject: { id: 'r1', birthDate: null }, + tags: ['a', 'b'], + }, + ]); + }); + + it('adapts empty pages and grouped counts without inventing rows', () => { + expect(shapeLoomRows([], ['id'])).toEqual([]); + expect( + aggregateToHistogram( + { + materialization: {} as any, + columns: ['status', 'count'], + rows: [{ status: 'active', count: 3 }], + }, + 'status', + ), + ).toEqual({ status: [{ key: 'active', count: 3 }] }); + }); +}); diff --git a/packages/core/src/features/loom/tests/query-contract.unit.test.ts b/packages/core/src/features/loom/tests/query-contract.unit.test.ts new file mode 100644 index 00000000..b24cba99 --- /dev/null +++ b/packages/core/src/features/loom/tests/query-contract.unit.test.ts @@ -0,0 +1,172 @@ +import { + buildLoomAggregateQuery, + buildLoomAggregationsQuery, + buildLoomDatasetQuery, + buildLoomDatasetSelectorQuery, + buildLoomRowsQuery, + normalizeLoomAggregateGraphQLResponse, + normalizeLoomRowsGraphQLResponse, +} from '../loomSlice'; +import { + isLoomDataType, + loomDatasetIdentityKey, + normalizeLegacyLoomOutput, +} from '../types'; + +describe('Loom GraphQL request contracts', () => { + it('uses canonical Loom data types', () => { + expect(isLoomDataType('Patient')).toBe(true); + expect(isLoomDataType('DocumentReference')).toBe(true); + expect(isLoomDataType('document_reference')).toBe(false); + + const request = buildLoomDatasetQuery('DocumentReference'); + expect(request.variables).toEqual({ dataType: 'DocumentReference' }); + expect(request.query).toContain('dataframeDataset'); + }); + + it.each([ + ['file', 'DocumentReference'], + ['document_reference', 'DocumentReference'], + ['research_subject', 'ResearchSubject'], + ['specimen', 'Specimen'], + ['medication_administration', 'MedicationAdministration'], + ['group_member', 'GroupMember'], + ['CustomOutput', 'CustomOutput'], + ])( + 'normalizes legacy output %s without closing custom outputs', + (input, expected) => { + expect(normalizeLegacyLoomOutput(input)).toEqual({ output: expected }); + }, + ); + + it('reports unsupported aliases instead of falling back to DocumentReference', () => { + expect(normalizeLegacyLoomOutput('unknown_output').diagnostic?.code).toBe( + 'UNSUPPORTED_LEGACY_OUTPUT', + ); + }); + + it('builds and keys immutable materialization selectors', () => { + const identity = { + selector: { + recipe: 'project_recipe', + translationVersion: 'r000001_abcd', + output: 'CustomOutput', + materializationId: 'materialization-1', + }, + } as const; + expect(buildLoomDatasetSelectorQuery(identity).variables).toEqual({ + input: { materializationId: 'materialization-1' }, + }); + expect(loomDatasetIdentityKey(identity)).toContain('CustomOutput'); + expect(loomDatasetIdentityKey(identity)).toContain('materialization-1'); + }); + + it('keeps immutable recipe selectors nested in GraphQL input', () => { + expect( + buildLoomRowsQuery({ + selector: { + recipe: 'project_recipe', + translationVersion: 'r000001_abcd', + output: 'CustomOutput', + }, + columns: ['id'], + }).variables, + ).toMatchObject({ + input: { + selector: { + recipe: 'project_recipe', + translationVersion: 'r000001_abcd', + output: 'CustomOutput', + }, + }, + }); + }); + + it('supports direct materialization IDs for rows and single aggregates', () => { + expect( + buildLoomRowsQuery({ materializationId: 'mat-1', columns: ['id'] }).variables, + ).toMatchObject({ input: { materializationId: 'mat-1' } }); + expect( + buildLoomAggregateQuery({ + materializationId: 'mat-1', + operation: 'COUNT', + }).variables, + ).toMatchObject({ input: { materializationId: 'mat-1' } }); + }); + + it('preserves opaque cursor pagination and sort variables', () => { + const request = buildLoomRowsQuery({ + dataType: 'ResearchSubject', + columns: ['id', 'status'], + filters: [{ column: 'status', op: 'EQ', value: 'active' }], + sort: { column: 'id', desc: true }, + first: 10, + after: 'opaque-next-cursor', + }); + expect(request.variables).toEqual({ + input: { + dataType: 'ResearchSubject', + columns: ['id', 'status'], + filters: [{ column: 'status', op: 'EQ', value: 'active' }], + sort: { column: 'id', desc: true }, + first: 10, + after: 'opaque-next-cursor', + }, + }); + }); + + it('builds grouped and filtered aggregate requests', () => { + const aggregate = buildLoomAggregateQuery({ + dataType: 'Specimen', + groupBy: ['status'], + filters: [{ column: 'project_id', op: 'EQ', value: 'p1' }], + operation: 'COUNT', + column: 'status', + }); + expect(aggregate.variables).toMatchObject({ + input: { + dataType: 'Specimen', + groupBy: ['status'], + operation: 'COUNT', + }, + }); + + const aggregations = buildLoomAggregationsQuery({ + dataType: 'MedicationAdministration', + fields: ['status'], + filters: [{ column: 'status', op: 'IN', value: ['active', 'stopped'] }], + }); + expect(aggregations.query).toContain('a0: dataframeAggregate'); + expect(aggregations.variables).toEqual({ + dataType: 'MedicationAdministration', + filters: [{ column: 'status', op: 'IN', value: ['active', 'stopped'] }], + }); + }); + + it('unwraps and shapes dataframeRows GraphQL responses', () => { + const response = normalizeLoomRowsGraphQLResponse({ + dataframeRows: { + materialization: {} as never, + columns: ['id', 'title'], + rows: [['file-1', 'example.tif']], + totalCount: 1, + pageInfo: { hasNextPage: false }, + }, + }); + + expect(response.rows).toEqual([{ id: 'file-1', title: 'example.tif' }]); + expect(response.totalCount).toBe(1); + }); + + it('unwraps and shapes dataframeAggregate GraphQL responses', () => { + const response = normalizeLoomAggregateGraphQLResponse({ + dataframeAggregate: { + materialization: {} as never, + columns: ['status', 'count'], + rows: [['active', '12']], + }, + }); + + expect(response.rows).toEqual([{ status: 'active', count: '12' }]); + }); +}); diff --git a/packages/core/src/features/loom/types.ts b/packages/core/src/features/loom/types.ts new file mode 100644 index 00000000..bd491f36 --- /dev/null +++ b/packages/core/src/features/loom/types.ts @@ -0,0 +1,277 @@ +import type { AggregationsData, JSONObject } from '../../types'; + +export const LOOM_DATA_TYPES = [ + 'Patient', + 'DocumentReference', + 'ResearchSubject', + 'Specimen', + 'MedicationAdministration', + 'GroupMember', +] as const; + +export type LoomDataType = (typeof LOOM_DATA_TYPES)[number]; + +export const isLoomDataType = (value: string): value is LoomDataType => + (LOOM_DATA_TYPES as ReadonlyArray).includes(value); + +export interface LoomDatasetSelector { + readonly recipe: string; + readonly translationVersion: string; + readonly output: string; + readonly materializationId?: string; +} + +export interface LoomSelectorDiagnostic { + readonly code: 'UNSUPPORTED_LEGACY_OUTPUT' | 'INVALID_DATASET_SELECTOR'; + readonly message: string; + readonly configPath?: string; +} + +const LEGACY_LOOM_OUTPUTS: Readonly> = { + file: 'DocumentReference', + document_reference: 'DocumentReference', + research_subject: 'ResearchSubject', + specimen: 'Specimen', + medication_administration: 'MedicationAdministration', + group_member: 'GroupMember', +}; + +/** Normalizes only the legacy aliases. Arbitrary PascalCase output names pass through. */ +export const normalizeLegacyLoomOutput = ( + value: string, +): { + readonly output?: string; + readonly diagnostic?: LoomSelectorDiagnostic; +} => { + const output = + LEGACY_LOOM_OUTPUTS[value] ?? + (/^[A-Z][A-Za-z0-9]*$/.test(value) ? value : undefined); + return output + ? { output } + : { + diagnostic: { + code: 'UNSUPPORTED_LEGACY_OUTPUT', + message: `Unsupported Loom output: ${value}`, + }, + }; +}; + +export const validateLoomDatasetSelector = ( + selector: LoomDatasetSelector, +): LoomSelectorDiagnostic | undefined => { + if (selector.materializationId?.trim()) return undefined; + if ( + selector.recipe.trim() && + selector.translationVersion.trim() && + selector.output.trim() + ) { + return undefined; + } + return { + code: 'INVALID_DATASET_SELECTOR', + message: + 'A Loom selector requires a materialization ID or an exact recipe, translation version, and output.', + }; +}; + +export type LoomDatasetIdentity = + | { readonly dataType: LoomDataType; readonly selector?: never; readonly materializationId?: never } + | { readonly dataType?: never; readonly selector: LoomDatasetSelector; readonly materializationId?: never } + | { readonly dataType?: never; readonly selector?: never; readonly materializationId: string }; + +export const loomDatasetIdentityKey = ( + identity: LoomDatasetIdentity, +): string => + identity.selector + ? [ + identity.selector.recipe, + identity.selector.translationVersion, + identity.selector.output, + identity.selector.materializationId ?? '', + ].join('|') + : identity.materializationId + ? `materialization|${identity.materializationId}` + : `legacy|${identity.dataType}`; + +export interface LoomDatasetRef { + readonly dataType: LoomDataType; +} + +export interface LoomColumn { + readonly name: string; + readonly clickhouseType: string; + readonly logicalType: string; + readonly nullable: boolean; + readonly repeated: boolean; + readonly filterable: boolean; + readonly sortable: boolean; + readonly aggregatable: boolean; +} + +export interface LoomDataset extends LoomDatasetRef { + readonly id: string; + readonly name: string; + readonly revision: string; + readonly state: 'PENDING' | 'LOADING' | 'READY' | 'FAILED' | string; + readonly columns: ReadonlyArray; + readonly rowCount: number; + readonly createdAt: string; + readonly readyAt?: string | null; + readonly error?: string | null; +} + +export interface LoomFilter { + readonly column: string; + readonly op: string; + readonly value: unknown; +} + +export interface LoomSort { + readonly column: string; + readonly desc?: boolean; +} + +export type LoomRowsRequest = LoomDatasetIdentity & { + readonly columns?: ReadonlyArray; + readonly filters?: ReadonlyArray; + readonly sort?: LoomSort; + readonly first?: number; + readonly after?: string | null; +}; + +export interface LoomRowsResponse { + readonly materialization: LoomDataset; + readonly columns: ReadonlyArray; + readonly rows: ReadonlyArray; + readonly totalCount?: number | null; + readonly pageInfo: { + readonly hasNextPage: boolean; + readonly endCursor?: string | null; + }; +} + +export type LoomAggregateRequest = LoomDatasetIdentity & { + readonly groupBy?: ReadonlyArray; + readonly filters?: ReadonlyArray; + readonly operation: string; + readonly column?: string; +}; + +export interface LoomAggregateResponse { + readonly materialization: LoomDataset; + readonly columns: ReadonlyArray; + readonly rows: ReadonlyArray; +} + +export type LoomAggregationsRequest = LoomDatasetIdentity & { + readonly fields: ReadonlyArray; + readonly filters?: ReadonlyArray; +}; + +export interface LoomAggregationsResponse { + readonly materialization: LoomDataset | null; + readonly data: AggregationsData; +} + +export interface LoomApiError { + readonly status: number | 'CUSTOM_ERROR' | 'FETCH_ERROR'; + readonly httpStatus?: number; + readonly data?: unknown; + readonly error?: string; + readonly code?: string; + readonly requestId?: string; + readonly retryable?: boolean; + readonly fieldPath?: string | null; +} + +export interface LoomQueryArgs { + readonly query: string; + readonly variables?: Record; +} + +export interface LoomGraphQLResponse { + readonly data?: T; + readonly errors?: ReadonlyArray; +} + +export interface LoomGraphQLError { + readonly message: string; + readonly locations?: ReadonlyArray<{ + readonly line: number; + readonly column: number; + }>; + readonly path?: ReadonlyArray; + readonly extensions?: LoomGraphQLErrorExtensions; +} + +export interface LoomGraphQLErrorExtensions { + readonly code?: string; + readonly requestId?: string; + readonly retryable?: boolean; + readonly fieldPath?: string | null; + readonly [key: string]: unknown; +} + +export interface LoomRequestMeta { + readonly endpoint: string; + readonly status?: number; + readonly requestId?: string; +} + +export interface LoomGraphQLRequestErrorOptions { + readonly status: number | 'CUSTOM_ERROR' | 'FETCH_ERROR'; + readonly message: string; + readonly data?: unknown; + readonly code?: string; + readonly requestId?: string; + readonly retryable?: boolean; + readonly fieldPath?: string | null; + readonly httpStatus?: number; + readonly meta?: LoomRequestMeta; + readonly cause?: unknown; +} + +export class LoomGraphQLRequestError extends Error implements LoomApiError { + readonly status: number | 'CUSTOM_ERROR' | 'FETCH_ERROR'; + readonly data?: unknown; + readonly code?: string; + readonly requestId?: string; + readonly retryable: boolean; + readonly fieldPath?: string | null; + readonly httpStatus?: number; + readonly meta?: LoomRequestMeta; + readonly isLoomGraphQLRequestError = true; + + constructor(options: LoomGraphQLRequestErrorOptions) { + super(options.message); + this.name = 'LoomGraphQLRequestError'; + this.status = options.status; + this.data = options.data; + this.code = options.code; + this.requestId = options.requestId; + this.retryable = options.retryable ?? false; + this.fieldPath = options.fieldPath; + this.httpStatus = options.httpStatus; + this.meta = options.meta; + + if (options.cause !== undefined) { + this.cause = options.cause; + } + } +} + +export const isLoomGraphQLRequestError = ( + error: unknown, +): error is LoomGraphQLRequestError => + error instanceof LoomGraphQLRequestError || + (typeof error === 'object' && + error !== null && + 'isLoomGraphQLRequestError' in error && + (error as { isLoomGraphQLRequestError?: unknown }) + .isLoomGraphQLRequestError === true); + +export interface LoomRequestOptions { + readonly endpoint?: string; + readonly headers?: HeadersInit; + readonly signal?: AbortSignal; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae542f7c..ea5c47d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ import { GEN3_GRIP_API, GEN3_GECKO_API, GEN3_GUPPY_API, + GEN3_LOOM_API, GEN3_MANIFEST_API, GEN3_MDS_API, GEN3_REDIRECT_URL, @@ -43,6 +44,7 @@ export * from './features/gen3Apps'; export * from './features/gecko'; export * from './features/graphQL'; export * from './features/guppy'; +export * from './features/loom'; export * from './features/manifest'; export * from './features/metadata'; export * from './features/modals'; @@ -53,6 +55,7 @@ export * from './features/syfon'; export * from './features/workspace'; export * from './features/grip'; export * from './features/configurator'; +export * from './features/explorerBuilder'; export * from './features/Directory'; export { @@ -62,6 +65,7 @@ export { GEN3_API, GEN3_DOWNLOADS_ENDPOINT, GEN3_GUPPY_API, + GEN3_LOOM_API, GEN3_GRIP_API, GEN3_GECKO_API, GEN3_FENCE_API, diff --git a/packages/core/src/reducers.ts b/packages/core/src/reducers.ts index a238e9c3..4aa8b56c 100644 --- a/packages/core/src/reducers.ts +++ b/packages/core/src/reducers.ts @@ -19,6 +19,10 @@ import { gripApiReducer, gripApiSliceReducerPath, } from './features/grip/gripApi'; +import { + loomApiReducer, + loomApiSliceReducerPath, +} from './features/loom/loomApi'; export const rootReducer = combineReducers({ gen3Services: gen3ServicesReducer, @@ -30,6 +34,7 @@ export const rootReducer = combineReducers({ activeWorkspace: activeWorkspaceReducer, [guppyApiSliceReducerPath]: guppyApiReducer, [gripApiSliceReducerPath]: gripApiReducer, + [loomApiSliceReducerPath]: loomApiReducer, [userAuthApiReducerPath]: userAuthApiReducer, }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 0e9cd6d4..1a797a6b 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -5,6 +5,7 @@ import { CoreState, rootReducer } from './reducers'; import { gen3ServicesReducerMiddleware } from './features/gen3/gen3Api'; import { guppyAPISliceMiddleware } from './features/guppy/guppyApi'; import { userAuthApiMiddleware } from './features/user/userSliceRTK'; +import { loomApiSliceMiddleware } from './features/loom/loomApi'; import { coreStoreListenerMiddleware } from './listeners'; import { persistReducer, @@ -50,6 +51,7 @@ export const setupCoreStore = (preloadedState?: Partial) => .concat( gen3ServicesReducerMiddleware, guppyAPISliceMiddleware, + loomApiSliceMiddleware, userAuthApiMiddleware, ) .prepend(coreStoreListenerMiddleware.middleware), // needs to be prepended, diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index f1f9ed17..30deefea 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -11,6 +11,9 @@ "strictNullChecks": true, "module": "esnext", "moduleResolution": "bundler", + "types": ["jest", "node"], + "incremental": true, + "tsBuildInfoFile": "dist/core.tsbuildinfo", "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 08f4e2aa..3f5d5d46 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -15,14 +15,14 @@ "types": "dist/index.d.ts", "scripts": { "lint": "eslint .", - "compile": "tsc", + "compile": "node ../../node_modules/@typescript/native/bin/tsc", "clean": "rm -rf dist", "rollup": "rollup --config rollup.config.mjs", "test": "jest unit", "test:watch": "jest unit --watch", "test:int": "echo No integrations tests yet", "test:all": "jest", - "types": "tsc --emitDeclarationOnly", + "types": "node ../../node_modules/@typescript/native/bin/tsc --emitDeclarationOnly", "copy-tailwind": "cp src/tailwind.cjs dist/tailwind.cjs", "build": "npm run compile && npm run types && npm run rollup", "build:clean": "npm run clean && npm run build", @@ -75,6 +75,7 @@ "default-composer": "^0.6.0", "dompurify": "^3.4.10", "echarts": "^6.0.0", + "elkjs": "^0.12.0", "fetch-retry": "^6.0.0", "file-saver": "^2.0.5", "filesize": "^11.0.13", @@ -110,7 +111,8 @@ "tailwind-styled-components": "^2.2.0", "use-deep-compare": "^1.3.0", "victory": "^37.3.6", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "devDependencies": { "@iconify/types": "^2.0.0", diff --git a/packages/frontend/rollup.config.mjs b/packages/frontend/rollup.config.mjs index 78dac05e..8b7e3c4e 100644 --- a/packages/frontend/rollup.config.mjs +++ b/packages/frontend/rollup.config.mjs @@ -76,6 +76,7 @@ const globals = { 'cm6-graphql': 'cm6Graphql', 'js-cookie': 'jsCookie', '@codemirror/autocomplete': 'codemirrorAutocomplete', + zod: 'zod', }; const config = [ diff --git a/packages/frontend/src/api/auth/sessionToken.ts b/packages/frontend/src/api/auth/sessionToken.ts index 718ce40a..bf46a54b 100644 --- a/packages/frontend/src/api/auth/sessionToken.ts +++ b/packages/frontend/src/api/auth/sessionToken.ts @@ -1,10 +1,11 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { getCookie } from 'cookies-next'; -import { decodeJwt, importSPKI, JWTPayload, jwtVerify } from 'jose'; +import { deleteCookie, getCookie } from 'cookies-next'; +import { decodeProtectedHeader, importSPKI, JWTPayload, jwtVerify } from 'jose'; import { fetchJWTKey } from './utils'; import { getWebTokenErrorResponse } from './errorHandler'; -export const isExpired = (value: number) => value - Date.now() > 0; +export const isExpired = (expirationSeconds: number) => + expirationSeconds * 1000 <= Date.now(); export interface JWTPayloadAndUser extends JWTPayload { context: Record; @@ -17,9 +18,10 @@ export interface JWTPayloadAndUser extends JWTPayload { */ export default async function (req: NextApiRequest, res: NextApiResponse) { try { - const access_token = getCookie('access_token', { req, res }); + const access_token = await getCookie('access_token', { req, res }); if (access_token) { - const jwtKey = await fetchJWTKey(); + const token = access_token as string; + const jwtKey = await fetchJWTKey(decodeProtectedHeader(token).kid); if (!jwtKey) { res.status(500).json({ message: 'No JWT Key to verify token', @@ -28,8 +30,22 @@ export default async function (req: NextApiRequest, res: NextApiResponse) { } // validate the token const publicKey = await importSPKI(jwtKey, 'RS256'); - await jwtVerify(access_token as string, publicKey); - const decodedAccessToken = decodeJwt(access_token as string) as JWTPayloadAndUser; + const { payload } = await jwtVerify(token, publicKey); + const decodedAccessToken = payload as JWTPayloadAndUser; + + // A credentials login token is useful only when there is no valid Fence + // browser session. Leaving both cookies in place makes client requests + // prefer the Bearer credential, which can override a newer access_token. + if (await getCookie('credentials_token', { req, res })) { + await deleteCookie('credentials_token', { + req, + res, + sameSite: 'lax', + httpOnly: process.env.NODE_ENV === 'production', + secure: process.env.NODE_ENV === 'production', + }); + } + return res.status(200).json({ issued: decodedAccessToken.iat, expires: decodedAccessToken.exp, diff --git a/packages/frontend/src/api/auth/sessionToken.unit.test.ts b/packages/frontend/src/api/auth/sessionToken.unit.test.ts new file mode 100644 index 00000000..8660a0ee --- /dev/null +++ b/packages/frontend/src/api/auth/sessionToken.unit.test.ts @@ -0,0 +1,12 @@ +import { isExpired } from './sessionToken'; + +describe('session token expiration', () => { + it('compares JWT seconds with the current time in milliseconds', () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000); + + expect(isExpired(1_799_999_999)).toBe(true); + expect(isExpired(1_800_000_001)).toBe(false); + + now.mockRestore(); + }); +}); diff --git a/packages/frontend/src/api/auth/utils.ts b/packages/frontend/src/api/auth/utils.ts index 3204fc9d..d2df370f 100644 --- a/packages/frontend/src/api/auth/utils.ts +++ b/packages/frontend/src/api/auth/utils.ts @@ -1,7 +1,7 @@ import { fetchFence } from '@gen3/core'; -interface Gen3JTWKeys { - keys: string[]; +interface Gen3JWTKeys { + keys: Array<[kid: string, pem: string]>; } /** @@ -15,8 +15,8 @@ interface Gen3JTWKeys { * @async * @returns {Promise} A Promise resolving to the JWT key as a string if available, or null if not. */ -export const fetchJWTKey = async () => { - const response = await fetchFence({ +export const fetchJWTKey = async (kid?: string) => { + const response = await fetchFence({ endpoint: '/jwt/keys', isJSON: true, }); @@ -24,8 +24,7 @@ export const fetchJWTKey = async () => { return null; } - if (response?.data?.keys.length && response?.data?.keys[0].length > 1) { - return response.data.keys[0][1]; - } - return null; + const keys = response?.data?.keys ?? []; + if (kid) return keys.find(([candidate]) => candidate === kid)?.[1] ?? null; + return keys.length === 1 ? keys[0][1] : null; }; diff --git a/packages/frontend/src/components/MessageCards/PageLoadBoundary.tsx b/packages/frontend/src/components/MessageCards/PageLoadBoundary.tsx new file mode 100644 index 00000000..2c2d4a33 --- /dev/null +++ b/packages/frontend/src/components/MessageCards/PageLoadBoundary.tsx @@ -0,0 +1,42 @@ +import React, { type PropsWithChildren } from 'react'; +import type { PageLoadProblem } from '../../lib/pageLoader'; +import PageLoadErrorCard from './PageLoadErrorCard'; + +const PageLoadBoundary = ({ + problems = [], + children, +}: PropsWithChildren<{ problems?: readonly PageLoadProblem[] }>) => { + const blocking = problems.filter(({ severity }) => severity === 'error'); + const warnings = problems.filter(({ severity }) => severity === 'warning'); + + if (blocking.length) { + return ( +
+ {blocking.map((problem, index) => ( + + ))} +
+ ); + } + + return ( + <> + {warnings.length ? ( +
+ {warnings.map((problem, index) => ( + + ))} +
+ ) : null} + {children} + + ); +}; + +export default PageLoadBoundary; diff --git a/packages/frontend/src/components/MessageCards/PageLoadBoundary.unit.test.tsx b/packages/frontend/src/components/MessageCards/PageLoadBoundary.unit.test.tsx new file mode 100644 index 00000000..822a7609 --- /dev/null +++ b/packages/frontend/src/components/MessageCards/PageLoadBoundary.unit.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import PageLoadBoundary from './PageLoadBoundary'; + +describe('PageLoadBoundary', () => { + it('shows warnings without hiding page content', () => { + render( + +
Page content
+
, + ); + + expect(screen.getByText('Using fallback configuration')).toBeInTheDocument(); + expect(screen.getByText('Page content')).toBeInTheDocument(); + }); + + it('replaces content with blocking errors and exposes request metadata', () => { + render( + +
Hidden content
+
, + ); + + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument(); + expect(screen.getByText('Dataframe backend unavailable')).toBeInTheDocument(); + expect(screen.getByText(/request request-123/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + + it('renders validation issue paths', () => { + render( + , + ); + fireEvent.click(screen.getByText('Configuration details')); + expect(screen.getByText('$.modes.0.endpoint')).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/src/components/MessageCards/PageLoadErrorCard.tsx b/packages/frontend/src/components/MessageCards/PageLoadErrorCard.tsx new file mode 100644 index 00000000..09b981b3 --- /dev/null +++ b/packages/frontend/src/components/MessageCards/PageLoadErrorCard.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type { PageLoadProblem } from '../../lib/pageLoader'; + +const PageLoadErrorCard = ({ problem }: { problem: PageLoadProblem }) => { + const metadata = [ + problem.source, + `HTTP ${problem.status}`, + problem.code, + problem.requestId ? `request ${problem.requestId}` : undefined, + ].filter(Boolean); + + return ( +
+

+ {problem.severity === 'error' + ? 'Unable to load this page' + : 'Some page content could not be loaded'} +

+

{problem.message}

+

{metadata.join(' · ')}

+ {problem.issues?.length ? ( +
+ + Configuration details + +
    + {problem.issues.map((issue, index) => ( +
  • + {issue.path}: {issue.message} +
  • + ))} +
+
+ ) : null} + {problem.retryable ? ( + + ) : null} +
+ ); +}; + +export default PageLoadErrorCard; diff --git a/packages/frontend/src/components/MessageCards/index.ts b/packages/frontend/src/components/MessageCards/index.ts index d995b407..92409f72 100644 --- a/packages/frontend/src/components/MessageCards/index.ts +++ b/packages/frontend/src/components/MessageCards/index.ts @@ -3,6 +3,8 @@ import WarningCard from './WarningCard'; import MessageCard from './MessageCard'; import CardContainer from './CardContainer'; import EmptyTableMessage from './EmptyTableMessage'; +import PageLoadBoundary from './PageLoadBoundary'; +import PageLoadErrorCard from './PageLoadErrorCard'; export { CardContainer, @@ -10,4 +12,6 @@ export { WarningCard, MessageCard, EmptyTableMessage, + PageLoadBoundary, + PageLoadErrorCard, }; diff --git a/packages/frontend/src/components/Protected/ProtectedContent.tsx b/packages/frontend/src/components/Protected/ProtectedContent.tsx index f0800229..6ca101fc 100644 --- a/packages/frontend/src/components/Protected/ProtectedContent.tsx +++ b/packages/frontend/src/components/Protected/ProtectedContent.tsx @@ -5,11 +5,8 @@ import { Text } from '@mantine/core'; import { type JWTSessionStatus } from '@gen3/core'; import { LoginView } from '../Modals/LoginModal'; -import Custom403Page from '../../pages/403/Custom403Page'; - export interface ProtectedContentProps { children?: ReactNode; - errorStatus?: number; } import { useGetAuthzMappingsQuery } from '@gen3/core'; @@ -24,7 +21,6 @@ const isAppHomePath = (path?: string): boolean => const AccessGate = ({ children, - errorStatus, onBlocked, }: ProtectedContentProps & { onBlocked: () => void }) => { const router = useRouter(); @@ -66,14 +62,10 @@ const AccessGate = ({ return null; // Will unmount shortly because parent will pick up the blocked state } - if (errorStatus === 403) { - return ; - } - return {children}; }; -const ProtectedContent = ({ children, errorStatus }: ProtectedContentProps) => { +const ProtectedContent = ({ children }: ProtectedContentProps) => { const router = useRouter(); const [stableStatus, setStableStatus] = useState< JWTSessionStatus | undefined @@ -97,11 +89,7 @@ const ProtectedContent = ({ children, errorStatus }: ProtectedContentProps) => { } if (stableStatus === 'issued') { - return ( - - {children} - - ); + return {children}; } if (pending) { diff --git a/packages/frontend/src/contracts/explorerBuilderFixtures.unit.test.ts b/packages/frontend/src/contracts/explorerBuilderFixtures.unit.test.ts new file mode 100644 index 00000000..07473031 --- /dev/null +++ b/packages/frontend/src/contracts/explorerBuilderFixtures.unit.test.ts @@ -0,0 +1,29 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const fixtureDirectory = resolve( + process.cwd(), + '../../docs/contracts/explorer-builder/v1', +); + +describe('Explorer Builder contract fixtures', () => { + it('matches every fixture against MANIFEST.sha256', () => { + const manifest = readFileSync( + resolve(fixtureDirectory, 'MANIFEST.sha256'), + 'utf8', + ) + .trim() + .split(/\r?\n/) + .filter(Boolean); + + expect(manifest.length).toBeGreaterThan(0); + for (const line of manifest) { + const [expectedDigest, fileName] = line.trim().split(/\s+/); + const digest = createHash('sha256') + .update(readFileSync(resolve(fixtureDirectory, fileName))) + .digest('hex'); + expect(digest).toBe(expectedDigest); + } + }); +}); diff --git a/packages/frontend/src/features/Analysis/configurationSchema.ts b/packages/frontend/src/features/Analysis/configurationSchema.ts new file mode 100644 index 00000000..9ea53ea9 --- /dev/null +++ b/packages/frontend/src/features/Analysis/configurationSchema.ts @@ -0,0 +1,4 @@ +export { + AnalysisConfigurationSchema, + AnalysisToolsConfigurationSchema, +} from '../../lib/config/schemas'; diff --git a/packages/frontend/src/features/CohortBuilder/CohortBuilder.tsx b/packages/frontend/src/features/CohortBuilder/CohortBuilder.tsx index ebb9fecf..29f2d023 100644 --- a/packages/frontend/src/features/CohortBuilder/CohortBuilder.tsx +++ b/packages/frontend/src/features/CohortBuilder/CohortBuilder.tsx @@ -18,11 +18,14 @@ export const useGetCurrentCohort = () => { }; const CohortBuilder = ({ - explorerConfig, + configuration: explorerConfiguration, + activeTab, + hideTabList = false, + onTabChange, sharedFiltersMap = null, - tabsLayout = 'left', - fileActions, }: CohortBuilderProps) => { + const { explorerConfig, tabsLayout = 'left', fileActions } = + explorerConfiguration; const dispatch = useCoreDispatch(); const [isTransitioning, setIsTransitioning] = React.useState(false); @@ -73,27 +76,31 @@ const CohortBuilder = ({ return ( -
+
- - {configuration.map((panelConfig: CohortPanelConfiguration) => ( - - {panelConfig.tabTitle} - - ))} - + {!hideTabList ? ( + + {configuration.map((panelConfig: CohortPanelConfiguration) => ( + + {panelConfig.tabTitle} + + ))} + + ) : null} {configuration.map((panelConfig: CohortPanelConfiguration) => ( getAllFieldsFromFilterConfigs(filters?.tabs ?? []), [filters?.tabs], @@ -118,22 +127,56 @@ export const CohortPanel = ({ const cohortFilters = useCoreSelector((state: CoreState) => selectIndexFilters(state, index), ); - const cohortId = useCoreSelector((state: CoreState) => - selectCurrentCohortId(state), + const loomFilters = useMemo(() => { + try { + return { + filters: convertFilterSetToLoomFilters(cohortFilters), + error: null, + }; + } catch (error) { + return { + filters: [], + error: + error instanceof Error ? error.message : 'Unsupported Loom filter', + }; + } + }, [cohortFilters]); + const { + data: dataset, + isError: isDatasetError, + isLoading: isDatasetLoading, + } = useGetLoomDatasetQuery(loomDataType ?? 'DocumentReference', { + skip: !loomDataType || Boolean(loomDataset), + }); + const { + data: selectedDataset, + isError: isSelectedDatasetError, + isLoading: isSelectedDatasetLoading, + } = useGetLoomDatasetBySelectorQuery( + { + selector: loomDataset ?? { + recipe: '', + translationVersion: '', + output: '', + }, + }, + { skip: !loomDataset }, ); + const activeDataset = loomDataset ? selectedDataset : dataset; const { data, isSuccess, isFetching: isAggsQueryFetching, isError: isAggsQueryError, - } = useGetAggsQuery({ - type: index, - fields, - filters: cohortFilters, - accessibility: accessLevel, - queryId: cohortId, - }); + } = useGetLoomAggregationsQuery( + { + ...(loomIdentity ?? { dataType: 'DocumentReference' as const }), + fields, + filters: loomFilters.filters, + }, + { skip: !loomIdentity || !!loomFilters.error }, + ); const chartKeys = useDeepCompareMemo( () => [...Object.keys(chartsSection?.charts ?? {}), ...Object.keys(charts)], @@ -145,16 +188,13 @@ export const CohortPanel = ({ isSuccess: isChartSuccess, isFetching: isChartFetching, isError: isChartError, - } = useGetAggsQuery( + } = useGetLoomAggregationsQuery( { - type: index, + ...(loomIdentity ?? { dataType: 'DocumentReference' as const }), fields: chartKeys, - filters: cohortFilters, - accessibility: accessLevel, - filterSelf: true, - queryId: cohortId, + filters: loomFilters.filters, }, - { skip: chartKeys.length === 0 }, + { skip: chartKeys.length === 0 || !loomIdentity || !!loomFilters.error }, ); const cleanChartData = useDeepCompareMemo(() => { @@ -322,34 +362,69 @@ export const CohortPanel = ({ : {}, [table?.columns], ); - + const { data: counts, isFetching: isCountsFetching, isSuccess: isCountSuccess, isError: isCountsError, - } = useGetCountsQuery({ - type: index, - filters: cohortFilters, - accessibility: accessLevel, - queryId: cohortId, - }); + } = useGetLoomCountQuery( + { + ...(loomIdentity ?? { dataType: 'DocumentReference' as const }), + filters: loomFilters.filters, + operation: 'COUNT', + }, + { skip: !loomIdentity || !!loomFilters.error }, + ); + if (!loomIdentity) { + return ; + } + if (loomFilters.error) { + return ; + } + if (isDatasetError || isSelectedDatasetError) { + return ( + + ); + } + if (isDatasetLoading || isSelectedDatasetLoading) { + return ( +
+
+
+ ); + } + if (!activeDataset) { + return ( + + ); + } + if (activeDataset.state !== 'READY') { + return ( + + ); + } if (isCountsError || isAggsQueryError) { return ; } // Show loading indicator if we don't have facet definitions yet but we're fetching - if (Object.keys(facetDefinitions).length === 0 && (isAggsQueryFetching || isCountsFetching)) { + if ( + Object.keys(facetDefinitions).length === 0 && + (isAggsQueryFetching || isCountsFetching) + ) { return ( -
-
-
+
+
+
); } return ( -
+
{/* Main flex container for filters and content */}
{/* Left panel for filters */} @@ -374,7 +449,7 @@ export const CohortPanel = ({ {/* Right panel for query expression + content */}
{/* Put QueryExpression at the top of content panel */}
@@ -390,6 +465,7 @@ export const CohortPanel = ({ totalCount={counts ?? 0} fields={table?.fields ?? []} filter={cohortFilters} + loomDataset={loomDataset} />
{Object.keys(summaryCharts).length !== 0 && ( @@ -424,6 +500,7 @@ export const CohortPanel = ({
; readonly filter: FilterSet; readonly sort?: string[]; + readonly loomDataset?: LoomDatasetSelector; } const DownloadsPanel = ({ @@ -107,6 +113,7 @@ const DownloadsPanel = ({ filter, accessibility, sort, + loomDataset, }: DownloadsPanelProps): JSX.Element => { const isUserLoggedIn = useIsUserLoggedIn(); const loginRequired = loginForDownload ? loginForDownload : false; @@ -146,6 +153,7 @@ const DownloadsPanel = ({ fields, filter, accessibility: accessibility ?? Accessibility.ALL, + selector: loomDataset, // sort: sort, // TODO add sort }); }, @@ -184,6 +192,7 @@ const DownloadsPanel = ({ fields, filter, accessibility: accessibility ?? Accessibility.ALL, + selector: loomDataset, // sort: sort, // TODO add sort }} key={button.title} diff --git a/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExploreTableDetails/QueryRowDetailsPanel.tsx b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExploreTableDetails/QueryRowDetailsPanel.tsx index b3b26561..459dd467 100644 --- a/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExploreTableDetails/QueryRowDetailsPanel.tsx +++ b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExploreTableDetails/QueryRowDetailsPanel.tsx @@ -1,7 +1,13 @@ import React, { useEffect, useMemo } from 'react'; import { LoadingOverlay, Stack, Table, Text } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import { useGetRawDataAndTotalCountsQuery } from '@gen3/core'; +import { + convertFilterSetToLoomFilters, + isLoomDataType, + useGetLoomDatasetQuery, + useGetLoomDatasetBySelectorQuery, + useGetLoomRowsQuery, +} from '@gen3/core'; import { MdKeyboardDoubleArrowLeft as BackIcon } from 'react-icons/md'; import ErrorCard from '../../../../components/MessageCards/ErrorCard'; import { TableDetailsPanelProps } from './types'; @@ -11,31 +17,15 @@ import { isArray } from 'lodash'; import { useStudyContext } from '../../../Study/StudyProvider'; import { SinglePageStudyDetailsPanel } from '../../../Study'; -interface QueryResponse { - data?: Record>; -} - -function isQueryResponse(obj: any): obj is QueryResponse { - // Considering that the data property can be optional - return ( - typeof obj === 'object' && - (obj.data === undefined || typeof obj.data === 'object') - ); -} - const ExtractData = ( - data: QueryResponse, - index: string, + row: Record | undefined, path?: string, ): Record => { - if (data === undefined || data === null) return {}; - if (data.data === undefined || data.data === null) return {}; + if (!row) return {}; - if (!isArray(data.data[index])) return {}; - - let rowData = data.data[index][0]; + let rowData = row; if (path) { - const tmp = JSONPath({ path: path, json: data.data[index][0] }); + const tmp = JSONPath({ path: path, json: row }); if (!isArray(tmp)) { return {}; } @@ -52,6 +42,7 @@ export const QueryRowDetailsPanel = ({ index, tableConfig, accessibility, + loomDataset, }: TableDetailsPanelProps) => { //const [queryGuppy, { data, isLoading, isError }] = useLazyGeneralGQLQuery(); const idField = tableConfig.detailsConfig?.idField; @@ -59,35 +50,77 @@ export const QueryRowDetailsPanel = ({ const { setStudyDetails } = useStudyContext(); const [opened, { open, close }] = useDisclosure(false); - const { data, isError, isFetching } = useGetRawDataAndTotalCountsQuery( + const loomDataType = isLoomDataType(index) ? index : null; + const loomIdentity = loomDataset + ? ({ selector: loomDataset } as const) + : loomDataType + ? ({ dataType: loomDataType } as const) + : null; + const loomFilters = useMemo(() => { + if (!idField || !id) return { filters: [], error: null }; + try { + return { + filters: convertFilterSetToLoomFilters({ + mode: 'and', + root: { + [idField]: buildNested(idField, { + operator: '=', + field: idField, + operand: id, + }), + }, + }), + error: null, + }; + } catch (error) { + return { + filters: [], + error: + error instanceof Error ? error.message : 'Unsupported Loom filter', + }; + } + }, [id, idField]); + const { + data: dataset, + isError: isDatasetError, + isLoading: isDatasetLoading, + } = useGetLoomDatasetQuery(loomDataType ?? 'DocumentReference', { + skip: !loomDataType || Boolean(loomDataset), + }); + const { + data: selectedDataset, + isError: isSelectedDatasetError, + isLoading: isSelectedDatasetLoading, + } = useGetLoomDatasetBySelectorQuery( { - type: index, - fields: tableConfig.fields as string[], - filters: { - mode: 'and', - root: { - [idField as string]: buildNested(idField as string, { - operator: '=', - field: idField as string, - operand: id as string, - }), - }, + selector: loomDataset ?? { + recipe: '', + translationVersion: '', + output: '', }, - offset: 0, - size: 1, - accessibility: accessibility, + }, + { skip: !loomDataset }, + ); + const activeDataset = loomDataset ? selectedDataset : dataset; + const { + data, + isError: isRowsError, + isFetching, + } = useGetLoomRowsQuery( + { + ...(loomIdentity ?? { dataType: 'DocumentReference' as const }), + columns: tableConfig.fields as string[], + filters: loomFilters.filters, + first: 1, }, { - skip: !idField || !id, // if no ide do not send request + skip: !loomIdentity || !idField || !id || !!loomFilters.error, }, ); const queryData = useMemo( - () => - isQueryResponse(data) - ? ExtractData(data, index, tableConfig?.detailsConfig?.dataPath) - : {}, - [data, index, tableConfig?.detailsConfig?.dataPath], + () => ExtractData(data?.rows?.[0], tableConfig?.detailsConfig?.dataPath), + [data, tableConfig?.detailsConfig?.dataPath], ); useEffect(() => { @@ -100,21 +133,37 @@ export const QueryRowDetailsPanel = ({ ); } - if (isError) { + if (!loomIdentity) { + return ; + } + if (loomFilters.error) { + return ; + } + if (isDatasetError || isSelectedDatasetError || isRowsError) { return ; } + if (isDatasetLoading || isSelectedDatasetLoading) { + return ; + } + if (!activeDataset || activeDataset.state !== 'READY') { + return ; + } // Inital attempt at using Study Details component return ( - - {simpleDetailsView ? - : -
Study Details Panel not configured
} + + {simpleDetailsView ? ( + + ) : ( +
Study Details Panel not configured
+ )}
); - }; export default QueryRowDetailsPanel; diff --git a/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExploreTableDetails/types.ts b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExploreTableDetails/types.ts index a37b0165..fcc98824 100644 --- a/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExploreTableDetails/types.ts +++ b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExploreTableDetails/types.ts @@ -1,16 +1,16 @@ import { DetailsPanelComponentProps } from '../../../../components/Details/types'; import { SummaryTable } from '../types'; -import { Accessibility } from '@gen3/core'; +import { Accessibility, LoomDatasetSelector } from '@gen3/core'; import { StudyPageConfig } from '../../../Study/types'; export interface TableDetailsPanelProps extends DetailsPanelComponentProps { index: string; tableConfig: SummaryTable; accessibility: Accessibility; + loomDataset?: LoomDatasetSelector; } -export interface TableDetailsReportPanelProps - extends DetailsPanelComponentProps { +export interface TableDetailsReportPanelProps extends DetailsPanelComponentProps { index?: string; tableConfig: SummaryTable; accessibility?: Accessibility; diff --git a/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTable.tsx b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTable.tsx index 48715b2e..28173a24 100644 --- a/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTable.tsx +++ b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTable.tsx @@ -1,12 +1,16 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDeepCompareMemo } from 'use-deep-compare'; import { CoreState, + convertFilterSetToLoomFilters, + isLoomDataType, isJSONValue, JSONObject, selectIndexFilters, useCoreSelector, - useGetRawDataAndTotalCountsQuery, + useGetLoomDatasetBySelectorQuery, + useGetLoomDatasetQuery, + useGetLoomRowsQuery, } from '@gen3/core'; import { MantineReactTable, @@ -20,18 +24,19 @@ import { TableIcons } from '../../../components/Tables/TableIcons'; import type { ExplorerTableProps, SummaryTable } from './types'; import { type TableDetailsPanelProps } from './ExploreTableDetails'; import { DetailsModal, DetailsDrawer } from '../../../components/Details'; -import { createTableColumns } from './utils'; +import { createTableColumns, includeAvailableSha256 } from './utils'; import SubtableStack from './SubTables/SubtableStack'; import { JSONPath } from 'jsonpath-plus'; import { StudyProvider } from '../../Study'; import QueryRowDetailsPanel from './ExploreTableDetails/QueryRowDetailsPanel'; +import { ErrorCard } from '../../../components/MessageCards'; const DEFAULT_PAGE_LIMIT_LABEL = 'Rows per Page (Limited to 10,0000):'; const DEFAULT_PAGE_LIMIT = 10000; /** - * Main table component for the explorer page. Fetches data from guppy using - * useGetRawDataAndTotalCountsQuery() hook that leverages guppy core API slices + * Main table component for the Explorer page. Fetches canonical Loom rows and + * adapts them to the existing table contract. * * @param index - Offset to use for fetching/displaying pages of rows * @param tableConfig - Inherited from ExplorerPageGetServerSideProps @@ -44,6 +49,7 @@ const ExplorerTable = ({ classNames, size = 'sm', fileActions, + loomDataset, }: ExplorerTableProps) => { const [pagination, setPagination] = useState({ pageIndex: 0, @@ -150,21 +156,122 @@ const ExplorerTable = ({ selectIndexFilters(state, index), ); - const { data, isLoading, isError, isFetching } = - useGetRawDataAndTotalCountsQuery({ - type: index, - fields: fields, - filters: cohortFilters, - offset: pagination.pageIndex * pagination.pageSize, - size: pagination.pageSize, - sort: - sorting.length > 0 - ? (sorting.map((x) => { - return { [x.id]: x.desc ? 'desc' : 'asc' }; - }) as Record[]) - : undefined, - accessibility: accessibility, - }); + const loomDataType = isLoomDataType(index) ? index : null; + const loomIdentity = loomDataset + ? ({ selector: loomDataset } as const) + : loomDataType + ? ({ dataType: loomDataType } as const) + : null; + const loomFilters = useMemo(() => { + try { + return { + filters: convertFilterSetToLoomFilters(cohortFilters), + error: null, + }; + } catch (error) { + return { + filters: [], + error: + error instanceof Error ? error.message : 'Unsupported Loom filter', + }; + } + }, [cohortFilters]); + const { + data: dataset, + isError: isDatasetError, + isLoading: isDatasetLoading, + } = useGetLoomDatasetQuery(loomDataType ?? 'DocumentReference', { + skip: !loomDataType || Boolean(loomDataset), + }); + const { + data: selectedDataset, + isError: isSelectedDatasetError, + isLoading: isSelectedDatasetLoading, + } = useGetLoomDatasetBySelectorQuery( + { + selector: loomDataset ?? { + recipe: '', + translationVersion: '', + output: '', + }, + }, + { skip: !loomDataset }, + ); + const activeDataset = loomDataset ? selectedDataset : dataset; + const queryFields = useMemo( + () => includeAvailableSha256(fields, activeDataset?.columns), + [activeDataset?.columns, fields], + ); + const [cursorLedger, setCursorLedger] = useState< + Record + >({ + 0: null, + }); + const querySignature = useMemo( + () => + JSON.stringify({ + loomIdentity, + loomFilters: loomFilters.filters, + sorting, + pageSize: pagination.pageSize, + }), + [loomIdentity, loomFilters.filters, sorting, pagination.pageSize], + ); + useEffect(() => { + setCursorLedger({ 0: null }); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }, [querySignature]); + + const { + data: loomRows, + isLoading, + isError: isRowsError, + isFetching, + } = useGetLoomRowsQuery( + { + ...(loomIdentity ?? { dataType: 'DocumentReference' as const }), + columns: queryFields, + filters: loomFilters.filters, + first: pagination.pageSize, + after: cursorLedger[pagination.pageIndex] ?? null, + sort: sorting[0] + ? { column: sorting[0].id, desc: sorting[0].desc } + : undefined, + }, + { skip: !loomIdentity || !!loomFilters.error }, + ); + useEffect(() => { + const nextCursor = loomRows?.pageInfo?.endCursor; + if (nextCursor) { + setCursorLedger((current) => + current[pagination.pageIndex + 1] === nextCursor + ? current + : { ...current, [pagination.pageIndex + 1]: nextCursor }, + ); + } + }, [loomRows, pagination.pageIndex]); + + const setTablePagination = useCallback( + ( + updater: + | MRT_PaginationState + | ((current: MRT_PaginationState) => MRT_PaginationState), + ) => { + setPagination((current) => { + const next = typeof updater === 'function' ? updater(current) : updater; + return next.pageIndex === 0 || next.pageIndex in cursorLedger + ? next + : current; + }); + }, + [cursorLedger], + ); + + const data = useMemo( + () => [...(loomRows?.rows ?? [])], + [loomRows?.rows], + ); + const isError = isRowsError || isDatasetError; const { totalRowCount, limitLabel } = useDeepCompareMemo(() => { const pageLimit = @@ -173,9 +280,9 @@ const ExplorerTable = ({ const totalRowCount = tableConfig?.pageLimit ? Math.min( pageLimit, - data?.data?._aggregation?.[index]._totalCount ?? pagination.pageSize, + loomRows?.totalCount ?? dataset?.rowCount ?? pagination.pageSize, ) - : (data?.data?._aggregation?.[index]._totalCount ?? pagination.pageSize); + : (loomRows?.totalCount ?? dataset?.rowCount ?? pagination.pageSize); const limitLabel = tableConfig?.pageLimit ? (tableConfig?.pageLimit?.label ?? DEFAULT_PAGE_LIMIT_LABEL) : 'Rows per Page:'; @@ -186,7 +293,7 @@ const ExplorerTable = ({ * @see https://www.mantine-react-table.com/docs/api/table-options * @param columns - column options table config * @see https://www.mantine-react-table.com/docs/api/column-options - * @param data - data array, from useGetRawDataAndTotalCountsQuery() + * @param data - data array, from the Loom row adapter * @param manualSorting - If this is true, you will be expected to sort your data before it is passed to the table. * @param manualPagination - If this is true, you will be expected to manually paginate the rows before passing them to the table 0. @@ -202,13 +309,13 @@ const ExplorerTable = ({ const table = useMantineReactTable({ columns: tableColumns as any[], //TODO: fix this - data: data?.data?.[index] ?? [], + data, enableColumnFilters: false, manualSorting: true, manualPagination: true, enableStickyHeader: true, paginateExpandedRows: false, - onPaginationChange: setPagination, + onPaginationChange: setTablePagination, onSortingChange: setSorting, enableTopToolbar: false, enableExpandAll: false, @@ -315,6 +422,37 @@ const ExplorerTable = ({ } : undefined, }); + if (!loomIdentity) { + return ; + } + if (loomFilters.error) { + return ; + } + if (isDatasetError || isSelectedDatasetError) { + return ( + + ); + } + if (isDatasetLoading || isSelectedDatasetLoading) { + return ( +
+
+
+ ); + } + if (!activeDataset) { + return ( + + ); + } + if (activeDataset.state !== 'READY') { + return ( + + ); + } + return ( @@ -323,12 +461,12 @@ const ExplorerTable = ({ title={`${String(tableConfig?.detailsConfig?.nodeType).charAt(0).toUpperCase() + String(tableConfig?.detailsConfig?.nodeType).slice(1)} / ${getFieldValue( tableConfig, rowSelection, - data?.data?.[index] ?? [], + data, 'project_id', )} / ${getFieldValue( tableConfig, rowSelection, - data?.data?.[index] ?? [], + data, tableConfig?.detailsConfig?.title as string, )}`} id={ @@ -342,6 +480,7 @@ const ExplorerTable = ({ classNames={tableConfig?.detailsConfig?.classNames} panelProps={{ index, + loomDataset, tableConfig, ...(tableConfig?.detailsConfig?.params ?? {}), accessibility, diff --git a/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTableCellRenderers.tsx b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTableCellRenderers.tsx index 685f16a1..8f899676 100644 --- a/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTableCellRenderers.tsx +++ b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTableCellRenderers.tsx @@ -1,4 +1,7 @@ -import { RenderFactoryTypedInstance, DefaultItemRenderer } from '../../../utils/RendererFactory'; +import { + RenderFactoryTypedInstance, + DefaultItemRenderer, +} from '../../../utils/RendererFactory'; import React, { ReactNode } from 'react'; import { isArray } from 'lodash'; import { Badge, Text } from '@mantine/core'; @@ -13,6 +16,22 @@ export type CellRendererFunction = ( ...args: any[] ) => ReactNode; +// TanStack logs an error before throwing when getValue() names a column that +// is not present. Optional Explorer columns must be read without probing it. +export const getSafeRowValue = ( + row: CellRendererFunctionProps['row'], + columnId: string, +): unknown => { + const original = row.original as Record | undefined; + if (original && Object.prototype.hasOwnProperty.call(original, columnId)) { + return original[columnId]; + } + return row + .getAllCells() + .find((cell) => cell.column.id === columnId) + ?.getValue(); +}; + // TODO need to type this export const RenderArrayCell: CellRendererFunction = ({ cell, @@ -60,8 +79,13 @@ export const RenderArrayCellNegativePositive = ({ return value; }; -const ValueCellRenderer = ({ cell }: CellRendererFunctionProps) => { - return {cell.getValue() as ReactNode}; +export const ValueCellRenderer = ({ cell }: CellRendererFunctionProps) => { + const value = cell.getValue(); + return ( + + {typeof value === 'boolean' ? String(value) : (value as ReactNode)} + + ); }; const ArrayCellFunctionCatalog = { @@ -88,10 +112,6 @@ const RenderLinkCell = ( ); }; - - - - let instance: RenderFactoryTypedInstance; export const ExplorerTableCellRendererFactory = @@ -102,50 +122,57 @@ export const ExplorerTableCellRendererFactory = return instance; }; - - export const RenderFileActions = ( props: CellRendererFunctionProps, ...args: unknown[] ) => { const { cell, row } = props; const arg = (args[0] || {}) as Record; - let fileActionsConfig = arg.fileActions as { - extensions: Record; - actions: Record; - } | undefined; + let fileActionsConfig = arg.fileActions as + | { + extensions: Record; + actions: Record; + } + | undefined; const fileActionsMap = arg.fileActionsMap as Record | undefined; let projectId = ''; - try { - const val = row.getValue('project_id'); - if (typeof val === 'string') projectId = val; - } catch (e) { - if (row.original && typeof (row.original as any).project_id === 'string') { - projectId = (row.original as any).project_id; - } - } - - if (!fileActionsConfig && fileActionsMap && projectId && fileActionsMap[projectId]) { + const projectValue = getSafeRowValue(row, 'project_id'); + if (typeof projectValue === 'string') projectId = projectValue; + + if ( + !fileActionsConfig && + fileActionsMap && + projectId && + fileActionsMap[projectId] + ) { fileActionsConfig = fileActionsMap[projectId]; } let fileNameStr = ''; - try { - const sourcePath = row.getValue('document_reference_source_path'); - if (typeof sourcePath === 'string') fileNameStr = sourcePath; - } catch (e) { - // ignore if column doesn't exist + const sourcePath = getSafeRowValue(row, 'document_reference_source_path'); + if (typeof sourcePath === 'string') fileNameStr = sourcePath; + + if (!fileNameStr) { + const attachmentUrl = getSafeRowValue( + row, + 'document_reference_content_attachment_url', + ); + if (typeof attachmentUrl === 'string') fileNameStr = attachmentUrl; } if (!fileNameStr) { - try { - const fn = row.getValue('file_name'); - if (typeof fn === 'string') fileNameStr = fn; - } catch (e) { - // ignore - } + const attachmentTitle = getSafeRowValue( + row, + 'document_reference_content_attachment_title', + ); + if (typeof attachmentTitle === 'string') fileNameStr = attachmentTitle; + } + + if (!fileNameStr) { + const fileName = getSafeRowValue(row, 'file_name'); + if (typeof fileName === 'string') fileNameStr = fileName; } if (!fileNameStr) { @@ -153,8 +180,15 @@ export const RenderFileActions = ( fileNameStr = typeof cellRef === 'string' ? cellRef : ''; } - const extension = fileNameStr.includes('.') ? fileNameStr.split('.').pop()?.toLowerCase() || '' : ''; - const actionsList = fileActionsConfig?.extensions?.[extension] || fileActionsConfig?.extensions?.['default'] || ['file_download']; + const extension = fileNameStr.includes('.') + ? fileNameStr.split('.').pop()?.toLowerCase() || '' + : ''; + const actionsList = + fileActionsConfig?.extensions?.[extension] || + fileActionsConfig?.extensions?.['default'] || + (arg.imageURL && ['tif', 'tiff'].includes(extension) + ? ['file_download', 'file_image'] + : ['file_download']); if (actionsList.length === 0) return ; @@ -163,13 +197,13 @@ export const RenderFileActions = ( {actionsList.map((actionName, index) => { const factory = ExplorerTableCellRendererFactory(); let actionRenderer: CellRendererFunction | undefined; - + if (factory.rendererExists('link', actionName)) { - actionRenderer = factory.getRenderer('link', actionName); + actionRenderer = factory.getRenderer('link', actionName); } else if (factory.rendererExists('string', actionName)) { - actionRenderer = factory.getRenderer('string', actionName); + actionRenderer = factory.getRenderer('string', actionName); } else if (factory.rendererExists('value', actionName)) { - actionRenderer = factory.getRenderer('value', actionName); + actionRenderer = factory.getRenderer('value', actionName); } if (actionRenderer && actionRenderer !== DefaultItemRenderer) { diff --git a/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTableCellRenderers.unit.test.tsx b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTableCellRenderers.unit.test.tsx new file mode 100644 index 00000000..2ef887f4 --- /dev/null +++ b/packages/frontend/src/features/CohortBuilder/ExplorerTable/ExplorerTableCellRenderers.unit.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { + ExplorerTableCellRendererFactory, + RenderFileActions, + ValueCellRenderer, +} from './ExplorerTableCellRenderers'; +import type { CellRendererFunctionProps } from './types'; + +it.each([true, false])('renders boolean cell value %s', (value) => { + const { getByText } = render( + value }, + } as CellRendererFunctionProps)} + />, + ); + + expect(getByText(String(value))).toBeInTheDocument(); +}); + +it('renders the legacy image action for TIFF files', () => { + const factory = ExplorerTableCellRendererFactory(); + factory.registerRenderer('link', 'file_download', () => ( + download + )); + factory.registerRenderer('link', 'file_image', () => image); + + const props = { + cell: { getValue: () => 'file-id' }, + row: { + original: { + document_reference_content_attachment_url: 'file:///slide.ome.tiff', + }, + getAllCells: () => [], + }, + } as unknown as CellRendererFunctionProps; + const { getByText } = render( + <>{RenderFileActions(props, { imageURL: '/image-viewer/view' })}, + ); + + expect(getByText('download')).toBeInTheDocument(); + expect(getByText('image')).toBeInTheDocument(); +}); diff --git a/packages/frontend/src/features/CohortBuilder/ExplorerTable/index.ts b/packages/frontend/src/features/CohortBuilder/ExplorerTable/index.ts index 749d8917..2340c1c4 100644 --- a/packages/frontend/src/features/CohortBuilder/ExplorerTable/index.ts +++ b/packages/frontend/src/features/CohortBuilder/ExplorerTable/index.ts @@ -4,6 +4,7 @@ import { ExplorerTableCellRendererFactory, registerExplorerDefaultCellRenderers, RenderFileActions, + getSafeRowValue, } from './ExplorerTableCellRenderers'; import { SummaryTableColumn } from './types'; @@ -15,6 +16,7 @@ export { ExplorerTableCellRendererFactory, registerExplorerDefaultCellRenderers, RenderFileActions, + getSafeRowValue, type ColumnDefinition, type CellRendererFunctionProps, }; diff --git a/packages/frontend/src/features/CohortBuilder/ExplorerTable/types.ts b/packages/frontend/src/features/CohortBuilder/ExplorerTable/types.ts index 3c392814..9e035654 100644 --- a/packages/frontend/src/features/CohortBuilder/ExplorerTable/types.ts +++ b/packages/frontend/src/features/CohortBuilder/ExplorerTable/types.ts @@ -6,7 +6,7 @@ import { MRT_RowData, MRT_TableInstance, } from 'mantine-react-table'; -import { Accessibility, JSONObject } from '@gen3/core'; +import { Accessibility, JSONObject, LoomDatasetSelector } from '@gen3/core'; import { ReactNode, RefObject } from 'react'; import { CellRendererFunction } from './ExplorerTableCellRenderers'; import { FileActionsConfig } from '../types'; @@ -81,6 +81,7 @@ export interface ExplorerTableProps { size?: string; classNames?: Partial; fileActions?: FileActionsConfig; + loomDataset?: LoomDatasetSelector; } export interface ExplorerTableColumnMRT { diff --git a/packages/frontend/src/features/CohortBuilder/ExplorerTable/utils.ts b/packages/frontend/src/features/CohortBuilder/ExplorerTable/utils.ts index df10a6db..b46e1506 100644 --- a/packages/frontend/src/features/CohortBuilder/ExplorerTable/utils.ts +++ b/packages/frontend/src/features/CohortBuilder/ExplorerTable/utils.ts @@ -9,18 +9,27 @@ import { type MRT_Column } from 'mantine-react-table'; import { ExplorerTableCellRendererFactory, RenderArrayCell, + ValueCellRenderer, } from './ExplorerTableCellRenderers'; import { FileActionsConfig } from '../types'; import { jsonPathAccessor } from '../../../components/Tables/utils'; import { ArrayCellRenderer } from './ArrayCellRenderer'; - export const isRecordAny = (obj: unknown): obj is Record => { if (Array.isArray(obj)) return false; return obj !== null && typeof obj === 'object'; }; +export const includeAvailableSha256 = ( + fields: ReadonlyArray, + datasetColumns?: ReadonlyArray<{ name: string }>, +): ReadonlyArray => + datasetColumns?.some((column) => column.name === 'sha256') && + !fields.includes('sha256') + ? [...fields, 'sha256'] + : fields; + export const createTableColumns = ( tableConfig: TableColumnsAndFields, fileActions?: FileActionsConfig, @@ -30,9 +39,9 @@ export const createTableColumns = ( const cellRendererFunc = columnDef?.type ? ExplorerTableCellRendererFactory().getRenderer( - columnDef?.type, - columnDef?.cellRenderFunction ?? 'default', - ) + columnDef?.type, + columnDef?.cellRenderFunction ?? 'default', + ) : undefined; const cellRendererFuncParams = @@ -47,11 +56,10 @@ export const createTableColumns = ( accessorFn: columnDef?.accessorPath ? jsonPathAccessor(columnDef.accessorPath) : undefined, - Cell: - cellRendererFunc - ? (cell: CellRendererFunctionProps) => + Cell: cellRendererFunc + ? (cell: CellRendererFunctionProps) => cellRendererFunc(cell, cellRendererFuncParams) - : undefined, + : ValueCellRenderer, size: columnDef?.width, enableSorting: columnDef?.sortable ?? undefined, @@ -68,9 +76,9 @@ export const createArrayTableColumns = ( const cellRendererFunc = columnDef?.type ? ExplorerTableCellRendererFactory().getRenderer( - columnDef?.type, - columnDef?.cellRenderFunction ?? 'default', - ) + columnDef?.type, + columnDef?.cellRenderFunction ?? 'default', + ) : undefined; const cellRendererFuncParams = @@ -85,11 +93,10 @@ export const createArrayTableColumns = ( accessorFn: columnDef?.accessorPath ? jsonPathAccessor(columnDef.accessorPath) : undefined, - Cell: - cellRendererFunc - ? (cell: CellRendererFunctionProps) => + Cell: cellRendererFunc + ? (cell: CellRendererFunctionProps) => ArrayCellRenderer(cellRendererFunc, cell, cellRendererFuncParams) - : RenderArrayCell, + : RenderArrayCell, size: columnDef?.width, enableSorting: columnDef?.sortable ?? undefined, diff --git a/packages/frontend/src/features/CohortBuilder/ExplorerTable/utils.unit.test.ts b/packages/frontend/src/features/CohortBuilder/ExplorerTable/utils.unit.test.ts new file mode 100644 index 00000000..1ec66f94 --- /dev/null +++ b/packages/frontend/src/features/CohortBuilder/ExplorerTable/utils.unit.test.ts @@ -0,0 +1,32 @@ +jest.mock('@gen3/core', () => ({ fieldNameToTitle: jest.fn() })); + +import { includeAvailableSha256 } from './utils'; + +describe('includeAvailableSha256', () => { + it('requests sha256 when Loom exposes it without adding a visible table field', () => { + expect( + includeAvailableSha256( + ['id', 'title'], + [{ name: 'id' }, { name: 'title' }, { name: 'sha256' }], + ), + ).toEqual(['id', 'title', 'sha256']); + }); + + it('does not request sha256 when the Loom dataset does not expose it', () => { + expect( + includeAvailableSha256(['id', 'title'], [ + { name: 'id' }, + { name: 'title' }, + ]), + ).toEqual(['id', 'title']); + }); + + it('does not duplicate a configured sha256 field', () => { + expect( + includeAvailableSha256(['id', 'sha256'], [ + { name: 'id' }, + { name: 'sha256' }, + ]), + ).toEqual(['id', 'sha256']); + }); +}); diff --git a/packages/frontend/src/features/CohortBuilder/FiltersPanel.tsx b/packages/frontend/src/features/CohortBuilder/FiltersPanel.tsx index 1332b4ec..bdccb5b4 100644 --- a/packages/frontend/src/features/CohortBuilder/FiltersPanel.tsx +++ b/packages/frontend/src/features/CohortBuilder/FiltersPanel.tsx @@ -17,7 +17,7 @@ export const FiltersPanel = ({ return (
{fields.map((facetDefinition) => { return createFacetCard({ diff --git a/packages/frontend/src/features/CohortBuilder/TabbedCohortBuilder.tsx b/packages/frontend/src/features/CohortBuilder/TabbedCohortBuilder.tsx index 2c1cad0a..a9a3ae96 100644 --- a/packages/frontend/src/features/CohortBuilder/TabbedCohortBuilder.tsx +++ b/packages/frontend/src/features/CohortBuilder/TabbedCohortBuilder.tsx @@ -1,22 +1,25 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; import { Stack } from '@mantine/core'; import { Accessibility, CombineMode, + convertFilterSetToLoomFilters, CoreState, extractEnumFilterValue, FacetDefinition, FacetType, isIntersection, - selectCurrentCohortId, + isLoomDataType, selectIndexFilters, useCoreSelector, - useGetAggsQuery, - useGetCountsQuery, + useGetLoomAggregationsQuery, + useGetLoomCountQuery, + useGetLoomDatasetQuery, usePrevious, } from '@gen3/core'; import FacetTabs from '../../components/facets/FacetTabs'; +import { ErrorCard } from '../../components/MessageCards'; import { classifyFacets, extractRangeValues, @@ -103,37 +106,57 @@ const TabbedCohortBuilder = ({ Accessibility.ALL, ); - const cohortId = useCoreSelector((state: CoreState) => - selectCurrentCohortId(state), - ); - const cohortFilters = useCoreSelector((state: CoreState) => selectIndexFilters(state, index), ); + const loomDataType = isLoomDataType(index) ? index : null; + const loomFilters = useMemo(() => { + try { + return { + filters: convertFilterSetToLoomFilters(cohortFilters), + error: null, + }; + } catch (error) { + return { + filters: [], + error: error instanceof Error ? error.message : 'Unsupported Loom filter', + }; + } + }, [cohortFilters]); + const { + data: dataset, + isError: isDatasetError, + isLoading: isDatasetLoading, + } = useGetLoomDatasetQuery(loomDataType ?? 'DocumentReference', { + skip: !loomDataType, + }); const { data, isSuccess, isFetching: isAggsQueryFetching, isError: isAggsQueryError, - } = useGetAggsQuery({ - type: index, + } = useGetLoomAggregationsQuery( + { + dataType: loomDataType ?? 'DocumentReference', fields: cohortBuilderFilters, - filters: cohortFilters, - accessibility: accessLevel, - queryId: cohortId, - }); + filters: loomFilters.filters, + }, + { skip: !loomDataType || !!loomFilters.error }, + ); const { data: counts, isSuccess: isCountSuccess, isError, - } = useGetCountsQuery({ - type: index, - filters: cohortFilters, - accessibility: accessLevel, - queryId: cohortId, - }); + } = useGetLoomCountQuery( + { + dataType: loomDataType ?? 'DocumentReference', + filters: loomFilters.filters, + operation: 'COUNT', + }, + { skip: !loomDataType || !!loomFilters.error }, + ); const [facetDefinitions, setFacetDefinitions] = useState< Record @@ -251,6 +274,25 @@ const TabbedCohortBuilder = ({ }; }, [getEnumFacetData, getRangeFacetData, index]); + if (!loomDataType) { + return ; + } + if (loomFilters.error) { + return ; + } + if (isDatasetError) { + return ; + } + if (isDatasetLoading) { + return Loading Loom dataset…; + } + if (!dataset) { + return ; + } + if (dataset.state !== 'READY') { + return ; + } + return ( item[referenceIdFieldInDataIndex], - ); + const refIDList = referenceRows + .map((item) => item[referenceIdFieldInDataIndex]) + .filter( + (value): value is string | number => + typeof value === 'string' || typeof value === 'number', + ); // create a filter of the ids to use in the resource index const refIdsFilter: FilterSet = { mode: 'and', root: { manifest_ids: { operator: 'in', - operands: refIDList as string[], + operands: refIDList, field: referenceIdFieldInResourceIndex, } as Includes, ...(dataFormat @@ -139,7 +155,7 @@ export const downloadToManifestAction = async ( }, }; - let resultManifest = await downloadJSONDataFromGuppy({ + let resultManifest = await downloadJSONDataFromLoom({ parameters: { ...cohortFilterParams, type: resourceIndexType, @@ -157,7 +173,7 @@ export const downloadToManifestAction = async ( resultManifest = resultManifest.filter( (x: JSONObject) => !!x[resourceIdField], ); - + resultManifest.forEach((x: JSONObject) => { if (typeof x[resourceIdField] === 'string') { x[resourceIdField] = [x[resourceIdField]]; diff --git a/packages/frontend/src/features/CohortBuilder/downloads/actions/downloadTabular.ts b/packages/frontend/src/features/CohortBuilder/downloads/actions/downloadTabular.ts index 19cf5db5..a79a6392 100644 --- a/packages/frontend/src/features/CohortBuilder/downloads/actions/downloadTabular.ts +++ b/packages/frontend/src/features/CohortBuilder/downloads/actions/downloadTabular.ts @@ -1,4 +1,9 @@ -import { downloadJSONDataFromGuppy, GuppyDownloadDataParams } from '@gen3/core'; +import { + downloadJSONDataFromLoom, + isLoomDataType, + LoomDownloadParams, + LoomDatasetSelector, +} from '@gen3/core'; import { handleDownload } from './utils'; import { jsonToCsv } from '../utils/jsonToCsv'; import { ActionButtonWithArgsFunction } from '../../types'; @@ -11,6 +16,7 @@ export interface DownloadTabularParams { filename?: string; accessibility?: any; sort?: any; + selector?: LoomDatasetSelector; } export const downloadTabularAction: ActionButtonWithArgsFunction = async ( @@ -26,23 +32,28 @@ export const downloadTabularAction: ActionButtonWithArgsFunction = async ( fileFields, type, filter, - accessibility, sort, filename, + selector, } = params as DownloadTabularParams; const downloadFilename = filename ?? `${type}_export.csv`; + const dataType = resourceIndexType || type; + if (!selector && !isLoomDataType(dataType)) { + onError?.(new Error(`Unsupported Loom data type: ${dataType}`)); + return; + } - const cohortFilterParams: GuppyDownloadDataParams = { + const cohortFilterParams: LoomDownloadParams = { filter, - type: resourceIndexType || type, + type: !selector && isLoomDataType(dataType) ? dataType : undefined, + selector, fields: fileFields, - accessibility, sort, format: 'json', }; try { - const data = await downloadJSONDataFromGuppy({ + const data = await downloadJSONDataFromLoom({ parameters: cohortFilterParams, onAbort, signal, diff --git a/packages/frontend/src/features/CohortBuilder/downloads/actions/downloadToFile.tsx b/packages/frontend/src/features/CohortBuilder/downloads/actions/downloadToFile.tsx index 59a044e6..38cd2390 100644 --- a/packages/frontend/src/features/CohortBuilder/downloads/actions/downloadToFile.tsx +++ b/packages/frontend/src/features/CohortBuilder/downloads/actions/downloadToFile.tsx @@ -1,4 +1,9 @@ -import { downloadFromGuppyToBlob, GuppyDownloadDataParams } from '@gen3/core'; +import { + downloadFromLoomToBlob, + isLoomDataType, + LoomDatasetSelector, + LoomDownloadParams, +} from '@gen3/core'; import { handleDownload } from './utils'; export const downloadToFileAction = async ( @@ -8,9 +13,18 @@ export const downloadToFileAction = async ( onAbort?: () => void, signal?: AbortSignal, ): Promise => { - // call the downloadFromGuppy function - await downloadFromGuppyToBlob({ - parameters: params as GuppyDownloadDataParams, + const selector = params.selector as LoomDatasetSelector | undefined; + if (!selector && !isLoomDataType(params.type)) { + onError?.(new Error(`Unsupported Loom data type: ${params.type}`)); + return; + } + // Call the principal-scoped Loom export endpoint. + await downloadFromLoomToBlob({ + parameters: { + ...(params as LoomDownloadParams), + type: selector ? undefined : params.type, + selector, + }, onDone: (data: Blob) => { handleDownload(data, params.filename); if (done) done(); diff --git a/packages/frontend/src/features/CohortBuilder/downloads/actions/tests/downloadManifest.unit.test.ts b/packages/frontend/src/features/CohortBuilder/downloads/actions/tests/downloadManifest.unit.test.ts index efb78947..73c3f432 100644 --- a/packages/frontend/src/features/CohortBuilder/downloads/actions/tests/downloadManifest.unit.test.ts +++ b/packages/frontend/src/features/CohortBuilder/downloads/actions/tests/downloadManifest.unit.test.ts @@ -1,6 +1,6 @@ -import { downloadToManifestAction, } from '../downloadManifest'; +import { downloadToManifestAction } from '../downloadManifest'; import { - downloadJSONDataFromGuppy, + downloadJSONDataFromLoom, } from '@gen3/core'; import { handleDownload } from '../utils'; @@ -31,7 +31,7 @@ describe('downloadToManifestAction function', () => { const onAbort = jest.fn(); const signal = {} as AbortSignal; - (downloadJSONDataFromGuppy as jest.Mock).mockReturnValue([ + (downloadJSONDataFromLoom as jest.Mock).mockReturnValue([ { 'object_id': 'mocked-object-id-1', 'md5sum': 'mocked-md5sum-1', @@ -63,14 +63,13 @@ describe('downloadToManifestAction function', () => { expect(handleDownload).toHaveBeenCalled(); expect(done).toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled(); - expect(downloadJSONDataFromGuppy).toHaveBeenCalledWith({ + expect(downloadJSONDataFromLoom).toHaveBeenCalledWith({ onAbort: onAbort, signal: signal, parameters: { filter: params.filter, type: params.type, fields: [params.referenceIdFieldInDataIndex, ...params.fileFields], - accessibility: params.accessibility, sort: params.sort, format: 'json', }, @@ -98,7 +97,7 @@ describe('downloadToManifestAction function', () => { const onAbort = jest.fn(); const signal = {} as AbortSignal; - (downloadJSONDataFromGuppy as jest.Mock).mockResolvedValue([]); + (downloadJSONDataFromLoom as jest.Mock).mockResolvedValue([]); await downloadToManifestAction(params, done, onError, onAbort, signal); diff --git a/packages/frontend/src/features/CohortBuilder/index.tsx b/packages/frontend/src/features/CohortBuilder/index.tsx index 63bd7a3b..3bf18756 100644 --- a/packages/frontend/src/features/CohortBuilder/index.tsx +++ b/packages/frontend/src/features/CohortBuilder/index.tsx @@ -12,6 +12,7 @@ import { registerCohortBuilderDefaultPreviewRenderers, registerExplorerDefaultCellRenderers, RenderFileActions, + getSafeRowValue, type TableDetailsPanelProps, type TableDetailsReportPanelProps, } from './ExplorerTable'; @@ -44,6 +45,7 @@ export { ExplorerTableDetailsPanelFactory, registerExplorerDefaultCellRenderers, RenderFileActions, + getSafeRowValue, registerCohortBuilderDefaultPreviewRenderers, QueryExpressionContext, QueryExpression, diff --git a/packages/frontend/src/features/CohortBuilder/types.ts b/packages/frontend/src/features/CohortBuilder/types.ts index 956b6d32..7b96fd7d 100644 --- a/packages/frontend/src/features/CohortBuilder/types.ts +++ b/packages/frontend/src/features/CohortBuilder/types.ts @@ -8,7 +8,7 @@ import { SummaryTable } from './ExplorerTable/types'; import { FacetSortType, FieldToName } from '../../components/facets/types'; import { DownloadButtonProps } from '../../components/Buttons/DropdownButtons'; import { Dispatch, SetStateAction } from 'react'; -import { Modals, SharedFieldMapping } from '@gen3/core'; +import { LoomDatasetSelector, Modals, SharedFieldMapping } from '@gen3/core'; import { StylingOverride } from '../../types/styling'; import { Gen3AppConfigData } from '../../lib/content/types'; import { FacetDefinition } from '@gen3/core'; @@ -45,6 +45,7 @@ export interface ManifestFieldsConfig { export interface DataTypeConfig { dataType: string; + loomDataset?: LoomDatasetSelector; nodeCountTitle?: string; accessibleFieldCheckList?: string[]; accessibleValidationField?: string; @@ -81,7 +82,7 @@ export interface CohortPanelConfiguration { buttons?: ReadonlyArray; // row of action buttons loginForDownload?: boolean; // login required for download sharedFiltersMap?: SharedFieldMapping; - preFilters?: Record; // Tab-specific filters (e.g. { project_id: ["HTAN_INT-BForePC"] }) + preFilters?: Record; // Tab-specific filters (e.g. { project_id: ["PROGRAM-PROJECT"] }) } export interface SharedFieldConfiguration { @@ -114,8 +115,11 @@ export interface CohortBuilderConfiguration extends Gen3AppConfigData { fileActions?: FileActionsConfig; } -export interface CohortBuilderProps - extends Omit { +export interface CohortBuilderProps { + configuration: CohortBuilderConfiguration; + activeTab?: string | null; + hideTabList?: boolean; + onTabChange?: (value: string | null) => void; sharedFiltersMap: SharedFieldMapping | null; } @@ -142,8 +146,10 @@ export type ActionButtonWithArgsFunction = ( signal?: AbortSignal, ) => Promise; -export interface DownloadButtonPropsWithAction - extends Omit { +export interface DownloadButtonPropsWithAction extends Omit< + DownloadButtonProps, + 'action' | 'actionArgs' +> { actionFunction: ActionButtonWithArgsFunction; actionArgs: Record; } diff --git a/packages/frontend/src/features/DataLibrary/configurationSchema.ts b/packages/frontend/src/features/DataLibrary/configurationSchema.ts new file mode 100644 index 00000000..b66b97d2 --- /dev/null +++ b/packages/frontend/src/features/DataLibrary/configurationSchema.ts @@ -0,0 +1 @@ +export { DataLibraryConfigurationSchema } from '../../lib/config/schemas'; diff --git a/packages/frontend/src/features/Dictionary/configurationSchema.ts b/packages/frontend/src/features/Dictionary/configurationSchema.ts new file mode 100644 index 00000000..2bec09f5 --- /dev/null +++ b/packages/frontend/src/features/Dictionary/configurationSchema.ts @@ -0,0 +1 @@ +export { DictionaryConfigurationSchema } from '../../lib/config/schemas'; diff --git a/packages/frontend/src/features/Discovery/configurationSchema.ts b/packages/frontend/src/features/Discovery/configurationSchema.ts new file mode 100644 index 00000000..edff4bd7 --- /dev/null +++ b/packages/frontend/src/features/Discovery/configurationSchema.ts @@ -0,0 +1 @@ +export { DiscoveryConfigurationSchema } from '../../lib/config/schemas'; diff --git a/packages/frontend/src/features/ExplorerBuilder/ExplorerBuilderPage.tsx b/packages/frontend/src/features/ExplorerBuilder/ExplorerBuilderPage.tsx new file mode 100644 index 00000000..a39cb861 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/ExplorerBuilderPage.tsx @@ -0,0 +1,652 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { + BuilderApiError, + ExplorerAuthoringDocument, + ProjectRecipeRevision, + RecipeAuthoringDocument, +} from '@gen3/core'; +import { + useGetAuthzMappingsQuery, + userHasMethodForServiceOnResource, +} from '@gen3/core'; +import { + useActivateExplorerRevisionMutation, + useCreateExplorerMutation, + useGetExplorerQuery, + useGetExplorersQuery, + useGetProjectRecipeQuery, + useGetProjectRecipeRevisionsQuery, + usePreviewProjectRecipeMutation, + usePublishExplorerMutation, + usePublishProjectRecipeMutation, + useSaveExplorerDraftMutation, + useSaveProjectRecipeDraftMutation, + useValidateExplorerMutation, + useValidateProjectRecipeMutation, +} from '@gen3/core'; +import { + ExplorerBuilderSessionProvider, + buildPreviewCacheKey, + schedulePreview, + useExplorerBuilderSession, +} from './session'; +import { GuidedBuilder } from './guided/GuidedBuilder'; + +const diagnosticsFromError = (error: unknown) => { + const candidate = error as + | (Partial & { + readonly diagnostics?: BuilderApiError['diagnostics']; + }) + | undefined; + return candidate?.diagnostics?.length + ? candidate.diagnostics + : [ + { + severity: 'error' as const, + code: 'BUILDER_ERROR', + message: 'The request failed.', + }, + ]; +}; + +const hasDiagnosticErrors = ( + diagnostics: ReadonlyArray<{ readonly severity: string }>, +) => diagnostics.some((diagnostic) => diagnostic.severity === 'error'); + +class LifecycleDiagnosticsError extends Error { + readonly diagnostics: ReturnType; + + constructor( + message: string, + diagnostics: ReturnType, + ) { + super(message); + this.name = 'LifecycleDiagnosticsError'; + this.diagnostics = diagnostics; + } +} + +/** + * Wait for the exact revision returned by publish. The API may take longer + * than a request timeout to materialize Loom outputs, so a short fixed retry + * loop would incorrectly turn a successful publication into a failure. + */ +export const waitForReadyRecipeRevision = async ( + initial: ProjectRecipeRevision, + refresh: () => Promise>, + options: { readonly intervalMs?: number; readonly maxWaitMs?: number } = {}, +): Promise => { + const intervalMs = options.intervalMs ?? 1000; + const maxWaitMs = options.maxWaitMs ?? 120_000; + const startedAt = Date.now(); + let revision = initial; + + for (;;) { + if (revision.status === 'READY') return revision; + if (revision.status === 'FAILED') { + throw new LifecycleDiagnosticsError( + 'Recipe publication failed.', + revision.diagnostics, + ); + } + if (Date.now() - startedAt >= maxWaitMs) { + throw new LifecycleDiagnosticsError( + 'Recipe publication is still in progress. Retry to continue polling.', + [ + { + severity: 'error', + code: 'RECIPE_PUBLICATION_TIMEOUT', + message: + 'The recipe is still materializing. No changes were lost; retry to continue.', + retryable: true, + }, + ], + ); + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + const revisions = await refresh(); + revision = + revisions.find((candidate) => candidate.id === initial.id) ?? revision; + } +}; + +const BuilderWorkspace = ({ + requestedCanUpdate, +}: { + readonly requestedCanUpdate: boolean; +}) => { + const { state, dispatch } = useExplorerBuilderSession(); + const project = state.project; + const { data: authzMapping = {} } = useGetAuthzMappingsQuery(); + const projectResource = `/programs/${project.organization}/projects/${project.project}`; + const canUpdate = + requestedCanUpdate && + [ + projectResource, + `/programs/${project.organization}/projects`, + `/programs/${project.organization}`, + '/programs', + '/', + '*', + ].some((path) => + userHasMethodForServiceOnResource('update', '*', path, authzMapping), + ); + const { data: recipe } = useGetProjectRecipeQuery(project); + const recipeRevisionsQuery = useGetProjectRecipeRevisionsQuery(project, { + pollingInterval: 5000, + }); + const recipeRevisions = useMemo( + () => recipeRevisionsQuery.data ?? [], + [recipeRevisionsQuery.data], + ); + const explorersQuery = useGetExplorersQuery(project); + const explorers = useMemo( + () => explorersQuery.data ?? [], + [explorersQuery.data], + ); + const [saveRecipe] = useSaveProjectRecipeDraftMutation(); + const [validateRecipe] = useValidateProjectRecipeMutation(); + const [previewRecipe] = usePreviewProjectRecipeMutation(); + const [publishRecipe] = usePublishProjectRecipeMutation(); + const [createExplorer] = useCreateExplorerMutation(); + const [saveExplorer] = useSaveExplorerDraftMutation(); + const [validateExplorer] = useValidateExplorerMutation(); + const [publishExplorer] = usePublishExplorerMutation(); + const [activateExplorer] = useActivateExplorerRevisionMutation(); + const [output, setOutput] = useState(''); + const [limit] = useState<10 | 25 | 50 | 100>(25); + const [configId, setConfigId] = useState(''); + const [shareUrl, setShareUrl] = useState(); + const [lifecycleStatus, setLifecycleStatus] = useState(); + const previewAbort = useRef<{ abort?: () => void } | undefined>(undefined); + const guidedPreviewCancel = useRef<(() => void) | undefined>(undefined); + const explorerQueryConfigId = configId || explorers[0]?.configId || ''; + const selectedExplorer = useGetExplorerQuery( + { ...project, configId: explorerQueryConfigId }, + { skip: !explorerQueryConfigId }, + ); + const readyRevisions = recipeRevisions.filter( + (revision) => revision.status === 'READY', + ); + const latestReadyRevision = readyRevisions.reduce< + ProjectRecipeRevision | undefined + >( + (latest, candidate) => + !latest || candidate.revisionNumber > latest.revisionNumber + ? candidate + : latest, + undefined, + ); + const selectedRecipeRevisionId = + state.selectedRecipeRevisionId ?? latestReadyRevision?.id; + + useEffect(() => { + if (!recipe || state.recipeDirty) return; + dispatch({ + type: 'loadRecipe', + draft: recipe.document, + version: recipe.draftVersion, + }); + }, [dispatch, recipe, state.recipeDirty]); + + useEffect(() => { + const explorer = selectedExplorer.data; + if (!explorer || state.explorerDirty) return; + dispatch({ + type: 'loadExplorer', + draft: explorer.draftContent, + version: explorer.draftVersion, + }); + }, [dispatch, selectedExplorer.data, state.explorerDirty]); + + useEffect(() => { + if (!configId && explorers[0]) setConfigId(explorers[0].configId); + }, [configId, explorers]); + + const editRecipeDocument = (draft: RecipeAuthoringDocument) => { + dispatch({ type: 'editRecipe', draft }); + }; + + const editExplorerDocument = (draft: ExplorerAuthoringDocument) => { + dispatch({ type: 'editExplorer', draft }); + }; + + const runPreview = async ( + requestedOutput = output, + recipeForPreview = state.recipeDraft, + ) => { + if (!requestedOutput) return; + if (requestedOutput !== output) setOutput(requestedOutput); + const key = await buildPreviewCacheKey( + project, + recipeForPreview, + requestedOutput, + limit, + ); + if ( + state.previewByOutput[requestedOutput]?.key === key && + (state.previewByOutput[requestedOutput]?.status === 'ready' || + state.previewByOutput[requestedOutput]?.status === 'loading') + ) + return; + previewAbort.current?.abort?.(); + dispatch({ type: 'previewLoading', output: requestedOutput, key }); + const request = previewRecipe({ + ...project, + recipe: recipeForPreview, + output: requestedOutput, + limit, + }); + previewAbort.current = request; + try { + dispatch({ + type: 'previewReady', + output: requestedOutput, + key, + data: await request.unwrap(), + }); + } catch (error) { + dispatch({ + type: 'previewError', + output: requestedOutput, + key, + diagnostics: diagnosticsFromError(error), + }); + } + }; + + const scheduleGuidedPreview = ( + name: string, + draft: RecipeAuthoringDocument, + ) => { + guidedPreviewCancel.current?.(); + guidedPreviewCancel.current = schedulePreview( + () => void runPreview(name, draft), + ); + }; + + const reportLifecycleError = ( + error: unknown, + scope: 'recipe' | 'explorer' | 'activation', + message: string, + ) => { + const diagnostics = diagnosticsFromError(error); + const candidate = error as BuilderApiError | undefined; + if ( + (scope === 'recipe' || scope === 'explorer') && + candidate?.status === 409 + ) { + dispatch({ + type: 'draftConflict', + conflict: { + scope, + currentVersion: candidate.currentVersion, + currentDigest: candidate.currentDigest, + updatedAt: candidate.updatedAt, + diagnostics, + }, + }); + } + dispatch({ + type: scope === 'recipe' ? 'recipeDiagnostics' : 'explorerDiagnostics', + diagnostics, + }); + setLifecycleStatus(message); + }; + + const createDefaultExplorerIfNeeded = async () => { + if (configId) { + return { configId, created: undefined }; + } + if ( + explorersQuery.isLoading || + explorersQuery.isFetching || + explorersQuery.isError + ) { + throw new LifecycleDiagnosticsError( + 'Explorer configurations are still loading.', + [ + { + severity: 'warning', + code: 'EXPLORER_LIST_LOADING', + message: 'Retry once the Explorer list has finished loading.', + retryable: true, + }, + ], + ); + } + if (explorers.length > 0) { + const first = explorers[0]; + setConfigId(first.configId); + return { configId: first.configId, created: undefined }; + } + const created = await createExplorer({ + ...project, + configId: 'default', + title: 'Default Explorer', + }).unwrap(); + setConfigId(created.configId); + return { configId: created.configId, created }; + }; + + const saveDraft = async () => { + if (!canUpdate) return; + setLifecycleStatus('Saving draft…'); + let scope: 'recipe' | 'explorer' = 'recipe'; + try { + if (state.recipeDirty) { + const saved = await saveRecipe({ + ...project, + draft: state.recipeDraft, + expectedDraftVersion: state.recipeDraftVersion, + }).unwrap(); + dispatch({ + type: 'recipeSaved', + draft: saved.document, + version: saved.draftVersion, + }); + } + + scope = 'explorer'; + const target = await createDefaultExplorerIfNeeded(); + // A newly-created Explorer already has a server-owned draft. Adopt it; + // only send a draft PUT when the steward actually edited the document. + if (target.created && !state.explorerDirty) { + dispatch({ + type: 'loadExplorer', + draft: target.created.draftContent, + version: target.created.draftVersion, + }); + } else if (state.explorerDirty) { + const saved = await saveExplorer({ + ...project, + configId: target.configId, + draft: state.explorerDraft, + expectedDraftVersion: + target.created?.draftVersion ?? state.explorerDraftVersion, + }).unwrap(); + dispatch({ + type: 'explorerSaved', + draft: saved.draftContent, + version: saved.draftVersion, + }); + } + setLifecycleStatus('Draft saved.'); + } catch (error) { + reportLifecycleError( + error, + scope, + 'Draft could not be saved. Resolve the diagnostics and retry.', + ); + } + }; + + const makeLive = async () => { + if (!canUpdate) return; + setLifecycleStatus('Saving draft…'); + let scope: 'recipe' | 'explorer' | 'activation' = 'recipe'; + try { + let recipeDraft = recipe; + if (state.recipeDirty || !recipeDraft) { + recipeDraft = await saveRecipe({ + ...project, + draft: state.recipeDraft, + expectedDraftVersion: state.recipeDraftVersion, + }).unwrap(); + dispatch({ + type: 'recipeSaved', + draft: recipeDraft.document, + version: recipeDraft.draftVersion, + }); + } + + let revision = recipeRevisions.find( + (candidate) => + candidate.id === selectedRecipeRevisionId && + candidate.status === 'READY', + ); + if (!revision || state.recipeDirty) { + scope = 'recipe'; + const validation = await validateRecipe({ + ...project, + recipe: recipeDraft.document, + }).unwrap(); + dispatch({ + type: 'recipeDiagnostics', + diagnostics: validation.diagnostics, + }); + if (hasDiagnosticErrors(validation.diagnostics)) { + throw new LifecycleDiagnosticsError( + 'Recipe validation failed.', + validation.diagnostics, + ); + } + setLifecycleStatus('Publishing recipe…'); + revision = await publishRecipe({ + ...project, + expectedDraftVersion: recipeDraft.draftVersion, + expectedAuthoringDigest: recipeDraft.authoringDigest, + }).unwrap(); + revision = await waitForReadyRecipeRevision( + revision, + async () => (await recipeRevisionsQuery.refetch()).data ?? [], + ); + dispatch({ type: 'selectRecipeRevision', revisionId: revision.id }); + } + + scope = 'explorer'; + const target = await createDefaultExplorerIfNeeded(); + let explorerDraft = state.explorerDraft; + let explorerDraftVersion = state.explorerDraftVersion; + let currentExplorer = + selectedExplorer.data ?? + explorers.find((candidate) => candidate.configId === target.configId); + if ( + !state.explorerDirty && + !target.created && + target.configId === explorerQueryConfigId + ) { + const refreshed = await selectedExplorer.refetch(); + currentExplorer = refreshed.data ?? currentExplorer; + } + if (target.created) { + explorerDraft = target.created.draftContent; + explorerDraftVersion = target.created.draftVersion; + if (!state.explorerDirty) { + dispatch({ + type: 'loadExplorer', + draft: explorerDraft, + version: explorerDraftVersion, + }); + } + } else if (!state.explorerDirty && currentExplorer) { + // A clean session can safely adopt a newer remote draft, preventing a + // stale local document from being republished after a reload. + explorerDraft = currentExplorer.draftContent; + explorerDraftVersion = currentExplorer.draftVersion; + } + if (state.explorerDirty) { + setLifecycleStatus('Saving Explorer draft…'); + const savedExplorer = await saveExplorer({ + ...project, + configId: target.configId, + draft: explorerDraft, + expectedDraftVersion: explorerDraftVersion, + }).unwrap(); + explorerDraft = savedExplorer.draftContent; + explorerDraftVersion = savedExplorer.draftVersion; + dispatch({ + type: 'explorerSaved', + draft: explorerDraft, + version: explorerDraftVersion, + }); + } + + const explorerValidation = await validateExplorer({ + ...project, + configId: target.configId, + draft: explorerDraft, + recipeRevisionId: revision.id, + }).unwrap(); + dispatch({ + type: 'explorerDiagnostics', + diagnostics: explorerValidation.diagnostics, + }); + if (hasDiagnosticErrors(explorerValidation.diagnostics)) { + throw new LifecycleDiagnosticsError( + 'Explorer validation failed.', + explorerValidation.diagnostics, + ); + } + + setLifecycleStatus('Publishing Explorer…'); + const published = await publishExplorer({ + ...project, + configId: target.configId, + expectedDraftVersion: explorerDraftVersion, + recipeRevisionId: revision.id, + }).unwrap(); + + // Refresh the CAS token immediately before activation. The Explorer may + // have been created or loaded while another release was being activated. + let expectedActiveReleaseId = + target.created?.activeReleaseId ?? + explorers.find((candidate) => candidate.configId === target.configId) + ?.activeReleaseId ?? + selectedExplorer.data?.activeReleaseId ?? + null; + if (!target.created && target.configId === explorerQueryConfigId) { + const refreshed = await selectedExplorer.refetch(); + expectedActiveReleaseId = refreshed.data?.activeReleaseId ?? null; + } + setLifecycleStatus('Making live…'); + scope = 'activation'; + const release = await activateExplorer({ + ...project, + configId: target.configId, + revisionId: published.id, + expectedActiveReleaseId, + }).unwrap(); + setShareUrl(release.shareUrl); + setLifecycleStatus('Live Explorer is ready.'); + } catch (error) { + reportLifecycleError( + error, + scope, + 'Could not make this Explorer live. Fix the diagnostics and retry.', + ); + } + }; + + const preview = output ? state.previewByOutput[output] : undefined; + return ( +
+ {state.conflict && ( +
+ Remote changes detected. Reload the remote draft or + copy your local JSON before continuing. +
+ + + +
+
+ )} + void runPreview(name, draft)} + preview={preview?.data} + previewStatus={preview?.status} + previewError={preview?.diagnostics?.map((diagnostic) => diagnostic.message).join(' ')} + previewOutput={output} + onRetryPreview={output ? () => void runPreview() : undefined} + onSaveDraft={() => void saveDraft()} + onMakeLive={() => void makeLive()} + onRecipeChange={editRecipeDocument} + organization={project.organization} + project={project.project} + recipe={state.recipeDraft} + /> + {preview?.status === 'loading' &&

Loading sample…

} + {lifecycleStatus && ( +

+ {lifecycleStatus} +

+ )} + {shareUrl && ( +

+ Immutable share URL: {shareUrl} +

+ )} +
+ ); +}; + +export const ExplorerBuilderPage = ({ + organization, + project, + canUpdate = process.env.NEXT_PUBLIC_EXPLORER_BUILDER_READ_ONLY !== 'true', +}: { + readonly organization: string; + readonly project: string; + readonly canUpdate?: boolean; +}) => ( + + + +); diff --git a/packages/frontend/src/features/ExplorerBuilder/explorer/ExplorerVisualEditor.tsx b/packages/frontend/src/features/ExplorerBuilder/explorer/ExplorerVisualEditor.tsx new file mode 100644 index 00000000..8eaefaf4 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/explorer/ExplorerVisualEditor.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { + isJSONObject, + type ExplorerAuthoringDocument, + type JSONObject, +} from '@gen3/core'; +export { + decorationCapability, + preserveIncompatibleDecorations, +} from './capabilities'; +export type { OutputColumnCapability } from './capabilities'; + +type TabRecord = JSONObject; + +const tabsOf = (document: ExplorerAuthoringDocument): TabRecord[] => + Array.isArray(document.tabs) + ? (document.tabs.filter(isJSONObject) as TabRecord[]) + : []; + +export const ExplorerVisualEditor = ({ + document, + onChange, + disabled = false, +}: { + readonly document: ExplorerAuthoringDocument; + readonly onChange: (document: ExplorerAuthoringDocument) => void; + readonly disabled?: boolean; +}) => { + const tabs = tabsOf(document); + return ( +
+
+

Explorer tabs

+ +
+
    + {tabs.map((tab, index) => ( +
  1. +
    + { + const next = [...tabs]; + next[index] = { ...tab, title: event.currentTarget.value }; + onChange({ ...document, schemaVersion: 1, tabs: next }); + }} + /> + +
    +
  2. + ))} +
+
+ ); +}; diff --git a/packages/frontend/src/features/ExplorerBuilder/explorer/ExplorerVisualEditor.unit.test.ts b/packages/frontend/src/features/ExplorerBuilder/explorer/ExplorerVisualEditor.unit.test.ts new file mode 100644 index 00000000..a1fdd7d5 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/explorer/ExplorerVisualEditor.unit.test.ts @@ -0,0 +1,29 @@ +import { + decorationCapability, + preserveIncompatibleDecorations, +} from './capabilities'; +import { emptyExplorerDocument } from './document'; + +describe('Explorer visual editor capability constraints', () => { + it('starts authoring documents in the frozen tabs shape', () => { + expect(emptyExplorerDocument()).toEqual({ schemaVersion: 1, tabs: [] }); + }); + + it('allows only capabilities advertised by the pinned output schema', () => { + expect(decorationCapability('filter', { filterable: true })).toBe(true); + expect(decorationCapability('sort', { filterable: true })).toBe(false); + expect(decorationCapability('aggregate', { aggregatable: false })).toBe( + false, + ); + }); + + it('preserves incompatible decorations with visible warning metadata', () => { + const decorated = preserveIncompatibleDecorations( + { missingField: { label: 'Keep me' } }, + {}, + ) as unknown as { _capabilityWarnings: string[] }; + expect(decorated._capabilityWarnings).toEqual([ + 'Missing output capability for missingField', + ]); + }); +}); diff --git a/packages/frontend/src/features/ExplorerBuilder/explorer/capabilities.ts b/packages/frontend/src/features/ExplorerBuilder/explorer/capabilities.ts new file mode 100644 index 00000000..4c493b40 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/explorer/capabilities.ts @@ -0,0 +1,26 @@ +export interface OutputColumnCapability { + readonly filterable?: boolean; + readonly sortable?: boolean; + readonly aggregatable?: boolean; + readonly logicalType?: string; +} + +export const decorationCapability = ( + decoration: 'filter' | 'sort' | 'aggregate', + capability: OutputColumnCapability | undefined, +): boolean => { + if (!capability) return false; + if (decoration === 'filter') return capability.filterable === true; + if (decoration === 'sort') return capability.sortable === true; + return capability.aggregatable === true; +}; + +export const preserveIncompatibleDecorations = >( + decorations: T, + capabilities: Readonly>, +): T => ({ + ...decorations, + _capabilityWarnings: Object.keys(decorations) + .filter((field) => !capabilities[field]) + .map((field) => `Missing output capability for ${field}`), +}) as T; diff --git a/packages/frontend/src/features/ExplorerBuilder/explorer/document.ts b/packages/frontend/src/features/ExplorerBuilder/explorer/document.ts new file mode 100644 index 00000000..64b66a4e --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/explorer/document.ts @@ -0,0 +1,6 @@ +import type { ExplorerAuthoringDocument } from '@gen3/core'; + +export const emptyExplorerDocument = (): ExplorerAuthoringDocument => ({ + schemaVersion: 1, + tabs: [], +}); diff --git a/packages/frontend/src/features/ExplorerBuilder/guided/GuidedBuilder.tsx b/packages/frontend/src/features/ExplorerBuilder/guided/GuidedBuilder.tsx new file mode 100644 index 00000000..95b9773f --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/guided/GuidedBuilder.tsx @@ -0,0 +1,1768 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Background, + BaseEdge, + Controls, + MarkerType, + ReactFlow, + ReactFlowProvider, + useReactFlow, + type EdgeProps, +} from '@xyflow/react'; +import { IconCopy, IconTrash } from '@tabler/icons-react'; +import type { + ExplorerAuthoringDocument, + JSONValue, + RecipeAuthoringDocument, + RecipeDraftPreview, + RecipeColumnCandidate, + RecipeColumnCandidateConnection, + SemanticConcept, + SemanticConceptCatalog, +} from '@gen3/core'; +import { fetchRecipeColumnCandidates, fetchSemanticConceptCatalog } from '@gen3/core'; +import { + type FhirFieldHint, + type FhirProjectMap, + type FhirTraversalHint, + scanFhirProjectMap, +} from './fhirProjectMap'; +import { ExplorerSamplePreview } from '../sample/ExplorerSamplePreview'; +import { layoutDatasetGraph, type GraphLayoutResult } from './graphLayout'; +import { + familyLabel, + conceptSelectionsFor, + isPartialSemanticCatalog, + recipeFamilyLabel, + semanticCatalogAvailability, + semanticConceptDisambiguator, + semanticConceptsFor, + semanticFieldRefForPath, + semanticFieldsFor, + semanticResourceFor, +} from './semanticConcepts'; + +type RecipeOutput = Record; + +const edgeId = (edge: FhirTraversalHint) => + `${edge.fromType}/${edge.label}/${edge.toType}`; + +const RoutedEdge = ({ + id, + data, + sourceX, + sourceY, + targetX, + targetY, + markerEnd, + style, + interactionWidth, +}: EdgeProps) => ( + +); + +const edgeTypes = { routed: RoutedEdge }; + +/** Refit after a pane resize; React Flow resizes its canvas but does not + * automatically recompute the viewport that made the graph readable. */ +const GraphViewportFitter = ({ + hostRef, + graphIdentity, +}: { + readonly hostRef: React.RefObject; + readonly graphIdentity: string; +}) => { + const graph = useReactFlow(); + useEffect(() => { + const host = hostRef.current; + if (!host) return undefined; + let frame = 0; + const fit = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + // Favor legible cards on first load. Users can pan to surrounding + // resources; an unreadable all-nodes thumbnail is not useful. + graph.fitView({ padding: 0.06, minZoom: 0.38, maxZoom: 1.15, duration: 160 }); + }); + }; + const observer = new ResizeObserver(fit); + observer.observe(host); + fit(); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [graph, graphIdentity, hostRef]); + return null; +}; + +const asRecord = (value: JSONValue | undefined): Record => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + +const outputsOf = (document: RecipeAuthoringDocument): RecipeOutput[] => + Array.isArray(document.outputs) + ? document.outputs.filter( + (item): item is RecipeOutput => + Boolean(item) && typeof item === 'object' && !Array.isArray(item), + ) + : []; + +const titleFor = (value: string) => + value + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[_.]/g, ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()); + +const friendlyResourceLabels = { + Patient: 'People', + ResearchSubject: 'Study participants', + Specimen: 'Biospecimens', + DocumentReference: 'Files and documents', + Observation: 'Measurements and findings', + Condition: 'Diagnoses and conditions', + DiagnosticReport: 'Diagnostic reports', + Procedure: 'Procedures', + MedicationAdministration: 'Medications given', + Medication: 'Medications', + BodyStructure: 'Body sites', + Group: 'Groups and cohorts', +} as const; + +const resourceLabel = (resourceType: string) => + friendlyResourceLabels[ + resourceType as keyof typeof friendlyResourceLabels + ] ?? titleFor(resourceType); + +export const rowGrainForResource = (resourceType: string): string => { + switch (resourceType.trim()) { + case 'Patient': + return 'patient'; + case 'Specimen': + return 'specimen'; + case 'DocumentReference': + return 'file'; + case 'Condition': + return 'diagnosis'; + case 'Observation': + return 'observation'; + case 'ResearchSubject': + return 'study_enrollment'; + default: + return 'resource'; + } +}; + +const resolveResourceType = ( + nodes: FhirProjectMap['nodes'], + candidate: string, +) => { + const normalized = candidate.replace(/[^A-Za-z0-9]/g, '').toLowerCase(); + return ( + nodes.find( + (node) => + node.resourceType.replace(/[^A-Za-z0-9]/g, '').toLowerCase() === + normalized, + )?.resourceType ?? + (normalized.length >= 5 + ? nodes.find((node) => + node.resourceType + .replace(/[^A-Za-z0-9]/g, '') + .toLowerCase() + .endsWith(normalized), + )?.resourceType + : undefined) + ); +}; + +const shortFieldPath = (field: FhirFieldHint, resourceType: string) => { + // `valuePath` is only the value portion of a structured Loom selector. The + // catalog's `path` is the canonical FHIR path; when older servers omit it, + // derive a complete path from sourcePath + valuePath instead of treating the + // value path as a root-relative selector. + const sourcePath = field.selector?.sourcePath?.trim(); + const valuePath = field.selector?.valuePath?.trim(); + const structuredPath = sourcePath && valuePath + ? sourcePath.endsWith(valuePath) + ? sourcePath + : `${sourcePath.replace(/\.$/, '')}.${valuePath.replace(/^\./, '')}` + : sourcePath || undefined; + const candidate = field.path?.trim() || structuredPath || field.fieldRef; + return candidate + .replace(new RegExp(`^${resourceType}[./]`), '') + .replace(/^root\./, '') + .replace(/^\./, ''); +}; + +const normalizedFieldPath = (field: FhirFieldHint, resourceType: string) => + shortFieldPath(field, resourceType) + .replace(/\[(?:\d+)?\]/g, '[]') + .replace(/\.\.+/g, '.') + .replace(/^\.+|\.+$/g, ''); + +/** + * Keep selectable data columns at the leaves of the populated FHIR catalog. + * Loom can return both a structural object (for example `name`) and its + * populated values (`name.family`, `name.given`). Selecting the container + * would produce an opaque object column, so it is useful context in the + * graph but not a steward-facing column choice. + */ +export const dataFieldsFor = ( + fields: ReadonlyArray, + resourceType: string, +): ReadonlyArray => { + const paths = fields.map((field) => normalizedFieldPath(field, resourceType)); + return fields.filter((field, index) => { + const path = paths[index]; + if (!path) return false; + return !paths.some((candidate, candidateIndex) => + candidateIndex !== index && + (candidate.startsWith(`${path}.`) || candidate.startsWith(`${path}[]`)), + ); + }); +}; + +const fieldName = (path: string, index: number, stableName?: string) => { + if (stableName?.trim()) return stableName.trim(); + const finalSegment = path + .replace(/\[\]/g, '') + .split('.') + .filter(Boolean) + .at(-1) + ?.replace(/[^A-Za-z0-9_]/g, '_'); + return finalSegment || `field_${index + 1}`; +}; + +const outputNameFor = (resourceType: string) => + resourceType.endsWith('s') ? resourceType : `${resourceType}s`; + +const uniqueOutputName = ( + requested: string, + existing: ReadonlyArray, +) => { + const used = new Set(existing.map(outputKey).filter(Boolean)); + if (!used.has(requested)) return requested; + let suffix = 2; + while (used.has(`${requested}_${suffix}`)) suffix += 1; + return `${requested}_${suffix}`; +}; + +/** Recipes must have unique physical output names. A previous draft could + * already be invalid, so normalize the complete document before preview. */ +const uniqueOutputNames = (outputs: ReadonlyArray) => { + const result: RecipeOutput[] = []; + for (const output of outputs) { + const name = outputKey(output) || 'output'; + const uniqueName = uniqueOutputName(name, result); + result.push(uniqueName === name ? output : { ...output, name: uniqueName }); + } + return result; +}; + +const safeAlias = (resourceType: string, used: Set) => { + const base = resourceType.replace(/[^A-Za-z0-9]/g, '_').toLowerCase() || 'related'; + let alias = base; + let suffix = 2; + while (used.has(alias) || alias === 'root') alias = `${base}_${suffix++}`; + used.add(alias); + return alias; +}; + +const defaultFields = ( + fields: ReadonlyArray, + resourceType?: string, +) => + (resourceType ? dataFieldsFor(fields, resourceType) : fields) + .filter((field) => Boolean(field.fieldRef)) + .sort((left, right) => { + const leftId = /(^|\.)id$/i.test(left.fieldRef) ? -1 : 0; + const rightId = /(^|\.)id$/i.test(right.fieldRef) ? -1 : 0; + return leftId - rightId; + }) + .slice(0, 6) + .map((field) => field.fieldRef); + +const uniqueFieldNames = (fields: ReadonlyArray, resourceType: string) => { + const used = new Set(); + return fields.map((field, index) => { + const base = fieldName(shortFieldPath(field, resourceType), index, field.columnName); + let name = base; + let suffix = 2; + while (used.has(name)) name = `${base}_${suffix++}`; + used.add(name); + return name; + }); +}; + +const candidateField = (candidate: RecipeColumnCandidate): FhirFieldHint => ({ + fieldRef: candidate.id, + label: candidate.label, + // Keep the raw key in the picker. `valueSelector` may already be qualified + // with a traversal alias and is only appropriate when lowering the recipe. + path: candidate.rawKey || candidate.valueSelector, + columnName: candidate.publicName, + selector: { valuePath: candidate.valueSelector }, + recipeCandidate: candidate, +}); + +const candidateNodePath = ( + output: RecipeOutput | undefined, + resourceType: string, +): ReadonlyArray | undefined => { + if (!output || typeof output.rootResourceType !== 'string') return undefined; + if (output.rootResourceType === resourceType) return []; + const visit = ( + traversals: ReadonlyArray, + path: ReadonlyArray, + ): ReadonlyArray | undefined => { + for (const traversalValue of traversals) { + const traversal = asRecord(traversalValue); + const alias = typeof traversal.alias === 'string' ? traversal.alias : ''; + const target = typeof traversal.toResourceType === 'string' ? traversal.toResourceType : ''; + if (!alias || !target) continue; + const nextPath = [...path, alias]; + if (target === resourceType) return nextPath; + const nested = Array.isArray(traversal.traversals) + ? visit(traversal.traversals, nextPath) + : undefined; + if (nested) return nested; + } + return undefined; + }; + return visit(Array.isArray(output.traversals) ? output.traversals : [], []); +}; + +const selectedNativeCandidates = ( + node: Record, + candidates: ReadonlyArray, + selected: ReadonlyArray, +): Record => { + if (candidates.length === 0) return node; + // Once this node is controlled by the recipe-aware picker, its native + // declarations are the only selection source. Keeping the old root-level + // concept list here would make Loom compile the same selection twice. + const { conceptSelections: _legacyConceptSelections, ...nodeWithoutLegacySelections } = node; + const selectedCandidates = candidates.filter((candidate) => selected.includes(candidate.id)); + const ordinary = selectedCandidates.filter((candidate) => + candidate.familyKind === 'FIELD' || candidate.familyKind === 'CATALOG_PROJECTION', + ); + const selectedByFamily = new Map(); + for (const candidate of selectedCandidates) { + const key = `${candidate.familyKind}:${candidate.familyName}`; + selectedByFamily.set(key, [...(selectedByFamily.get(key) ?? []), candidate]); + } + const candidatesByFamily = new Map(); + for (const candidate of candidates) { + const key = `${candidate.familyKind}:${candidate.familyName}`; + candidatesByFamily.set(key, [...(candidatesByFamily.get(key) ?? []), candidate]); + } + const updateFamily = (key: 'dynamicColumns' | 'pivots', kind: 'DYNAMIC' | 'PIVOT') => + (Array.isArray(node[key]) ? node[key].map(asRecord) : []).map((family) => { + const name = typeof family.name === 'string' ? family.name : ''; + const familyKey = `${kind}:${name}`; + if (!candidatesByFamily.has(familyKey)) return family; + return { ...family, columnMode: 'SELECTED', columns: (selectedByFamily.get(familyKey) ?? []).map((candidate) => candidate.selectionKey) }; + }); + const extensions = (Array.isArray(node.extensionColumns) ? node.extensionColumns.map(asRecord) : []).map((family) => { + const name = typeof family.name === 'string' ? family.name : ''; + const familyKey = `EXTENSION:${name}`; + if (!candidatesByFamily.has(familyKey)) return family; + return { + ...family, + columnMode: 'SELECTED', + columns: (selectedByFamily.get(familyKey) ?? []).flatMap((candidate) => { + if (!candidate.extensionMapping) return []; + try { + const parsed: unknown = JSON.parse(candidate.extensionMapping); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? [parsed as JSONValue] : []; + } catch { + return []; + } + }), + }; + }); + // Catalog projections are discovery-only declarations. Once one of their + // leaves is chosen, lower all of the offered leaves as ordinary fields and + // remove the projection so it cannot rediscover extra columns later. + const controlsCatalogProjections = candidates.some((candidate) => candidate.familyKind === 'CATALOG_PROJECTION'); + return { + ...nodeWithoutLegacySelections, + fields: ordinary.map((candidate) => ({ name: candidate.selectionKey, expr: { select: candidate.valueSelector } })), + ...(controlsCatalogProjections ? { catalogProjections: [] } : {}), + dynamicColumns: updateFamily('dynamicColumns', 'DYNAMIC'), + pivots: updateFamily('pivots', 'PIVOT'), + extensionColumns: extensions, + }; +}; + +const mapFromRecipe = (document: RecipeAuthoringDocument): FhirProjectMap => { + const nodes = new Map(); + for (const output of outputsOf(document)) { + const resourceType = typeof output.rootResourceType === 'string' ? output.rootResourceType : ''; + if (!resourceType) continue; + const fields = Array.isArray(output.fields) + ? output.fields.map(asRecord).map((field) => { + const expr = asRecord(field.expr); + const select = typeof expr.select === 'string' ? expr.select.replace(/^root\./, '') : ''; + return { + fieldRef: `${resourceType}.${select}`, + label: typeof field.name === 'string' ? titleFor(field.name) : select, + path: select, + selector: { valuePath: select }, + }; + }).filter((field) => field.path) + : []; + nodes.set(resourceType, { resourceType, fields, traversals: [] }); + } + return { nodes: [...nodes.values()], edges: [] }; +}; + +const selectedOutputOf = (document: RecipeAuthoringDocument) => outputsOf(document)[0]; + +const outputKey = (output: RecipeOutput | undefined) => + typeof output?.name === 'string' ? output.name : undefined; + +const outputId = (name: string) => + name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'table'; + +const recipeFieldRef = ( + field: Record, + resourceType: string, + catalog: SemanticConceptCatalog | null, + selections: ReadonlyArray> = [], +) => { + const concepts = semanticConceptsFor(catalog, resourceType); + const conceptId = typeof field.conceptId === 'string' ? field.conceptId : ''; + if (conceptId && concepts.some((concept) => concept.id === conceptId)) return conceptId; + const columnName = typeof field.columnName === 'string' + ? field.columnName + : typeof field.name === 'string' ? field.name : ''; + const selection = selections.find((candidate) => candidate.columnName === columnName); + const selectedConceptId = typeof selection?.conceptId === 'string' ? selection.conceptId : ''; + if (selectedConceptId && concepts.some((concept) => concept.id === selectedConceptId)) return selectedConceptId; + const expression = asRecord(field.expr); + const path = typeof expression.select === 'string' + ? expression.select.replace(/^root\./, '') + : ''; + return semanticFieldRefForPath(catalog, resourceType, path) ?? ''; +}; + +const renameTraversalField = (traversals: JSONValue[], column: string, value: string): JSONValue[] => + traversals.map((candidate) => { + const traversal = asRecord(candidate); + const alias = typeof traversal.alias === 'string' ? traversal.alias : ''; + const prefix = alias ? `${alias}__` : ''; + const fields = Array.isArray(traversal.fields) ? traversal.fields.map(asRecord) : []; + const nextFields = fields.map((field) => { + const name = typeof field.name === 'string' ? field.name : ''; + return column === `${prefix}${name}` ? { ...field, name: value } : field; + }); + const nested = Array.isArray(traversal.traversals) + ? renameTraversalField(traversal.traversals, column, value) + : traversal.traversals; + return { ...traversal, fields: nextFields, ...(nested ? { traversals: nested } : {}) }; + }); + +const hydrateRecipeTraversal = ( + output: RecipeOutput, + map: FhirProjectMap, + root: string, + catalog: SemanticConceptCatalog | null, +) => { + const path: FhirTraversalHint[] = []; + const fieldsByNode: Record = {}; + const visit = (parent: string, traversals: JSONValue[]) => { + for (const candidate of traversals) { + const traversal = asRecord(candidate); + const child = typeof traversal.toResourceType === 'string' ? traversal.toResourceType : ''; + const label = typeof traversal.name === 'string' ? traversal.name : ''; + const edge = map.edges.find((item) => item.fromType === parent && item.toType === child && item.label === label); + if (!edge) continue; + path.push(edge); + const alias = typeof traversal.alias === 'string' ? traversal.alias : ''; + const selections = Array.isArray(traversal.conceptSelections) + ? traversal.conceptSelections.map(asRecord) + : []; + const selected = Array.isArray(traversal.fields) ? traversal.fields.map(asRecord).map((field) => { + const expression = asRecord(field.expr); + const select = typeof expression.select === 'string' ? expression.select.replace(new RegExp(`^${alias}\\.`), '') : ''; + const node = map.nodes.find((item) => item.resourceType === child); + return recipeFieldRef(field, child, catalog, selections) || (node?.fields.find((fieldHint) => shortFieldPath(fieldHint, child) === select)?.fieldRef ?? ''); + }).filter(Boolean) : []; + fieldsByNode[child] = selected; + if (Array.isArray(traversal.traversals)) visit(child, traversal.traversals); + } + }; + if (Array.isArray(output.traversals)) visit(root, output.traversals); + return { path, fieldsByNode }; +}; + +const connectedGraph = ( + map: FhirProjectMap, + root: string, + depth: number, +) => { + const distances = new Map([[root, 0]]); + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift()!; + const currentDistance = distances.get(current) ?? 0; + if (currentDistance >= Math.min(depth, 4)) continue; + for (const edge of map.edges) { + if (edge.edgeCount <= 0) continue; + const next = edge.fromType === current + ? edge.toType + : edge.toType === current + ? edge.fromType + : undefined; + if (next && !distances.has(next)) { + distances.set(next, currentDistance + 1); + queue.push(next); + } + } + } + // A disconnected resource cannot participate in a traversal. Keep the + // canvas focused on resources that have at least one currently visible + // relationship instead of presenting floating, unactionable cards. + const connectedTypes = new Set( + map.edges + .filter((edge) => edge.edgeCount > 0) + .flatMap((edge) => [edge.fromType, edge.toType]), + ); + const nodes = map.nodes.filter((node) => connectedTypes.has(node.resourceType)); + const edges = map.edges.filter( + (edge) => + edge.edgeCount > 0, + ); + return { nodes, edges, distances }; +}; + +const FlowGraph = ({ + map, + root, + depth, + selectedPath, + selectedNodeType, + onNodeSelect, + reachableEdgeIds, + onEdgeSelect, + onPaneClick, + showSparseData, + disabled, +}: { + readonly map: FhirProjectMap; + readonly root: string; + readonly depth: number; + readonly selectedPath: ReadonlyArray; + readonly selectedNodeType: string; + readonly onNodeSelect: (resourceType: string) => void; + readonly reachableEdgeIds: ReadonlySet; + readonly onEdgeSelect: (edge: FhirTraversalHint) => void; + readonly onPaneClick: () => void; + readonly showSparseData: boolean; + readonly disabled: boolean; + readonly recipeSource?: 'platform-default' | 'project-draft'; +}) => { + const graphViewportRef = useRef(null); + const selectedSparseKey = selectedPath + .filter((edge) => edge.edgeCount < 10) + .map(edgeId) + .sort() + .join('|'); + const graphMap = useMemo(() => { + if (showSparseData) return map; + const selectedKeys = new Set(selectedSparseKey.split('|').filter(Boolean)); + const edges = map.edges.filter( + (edge) => + edge.edgeCount >= 10 || + selectedKeys.has(edgeId(edge)), + ); + return { + nodes: map.nodes, + edges, + }; + }, [map, selectedSparseKey, showSparseData]); + const graph = useMemo( + () => connectedGraph(graphMap, root, depth), + [graphMap, root, depth], + ); + const maxDocuments = Math.max(1, ...graph.nodes.map((node) => node.documentCount ?? 0)); + const nodeDimensions = useMemo( + () => + graph.nodes.map((node) => { + const scale = Math.sqrt((node.documentCount ?? 0) / maxDocuments); + return { + id: node.resourceType, + width: 190 + Math.round(scale * 55), + height: 74 + Math.round(scale * 18), + }; + }), + [graph.nodes, maxDocuments], + ); + const [layout, setLayout] = useState(); + useEffect(() => { + let current = true; + setLayout(undefined); + void layoutDatasetGraph( + nodeDimensions, + graph.edges.map((edge) => ({ + id: edgeId(edge), + source: edge.fromType, + target: edge.toType, + })), + ).then((nextLayout) => { + if (current) setLayout(nextLayout); + }); + return () => { + current = false; + }; + }, [graph.edges, nodeDimensions]); + + if (graph.nodes.length === 0) return null; + if (!layout) { + return
Arranging the dataset graph…
; + } + const nodes = graph.nodes.map((node) => { + const scale = Math.sqrt((node.documentCount ?? 0) / maxDocuments); + const isRoot = node.resourceType === root; + const isSelected = node.resourceType === selectedNodeType; + const isInPath = isRoot || selectedPath.some((edge) => edge.toType === node.resourceType); + const isReachable = graph.edges.some((edge) => + edge.toType === node.resourceType && reachableEdgeIds.has(edgeId(edge)), + ); + return { + id: node.resourceType, + data: { + label: `${resourceLabel(node.resourceType)}${isRoot ? ' · ROW START' : isInPath ? ' · IN TABLE' : isReachable ? ' · NEXT' : ''}${resourceLabel(node.resourceType) !== titleFor(node.resourceType) ? `\n${titleFor(node.resourceType)}` : ''}${node.documentCount ? ` · ${node.documentCount.toLocaleString()} records` : ''}${node.fields.length ? `\n${node.fields.length} available data columns` : ''}`, + }, + position: layout.positions.get(node.resourceType) ?? { x: 0, y: 0 }, + style: { + width: 190 + Math.round(scale * 55), + minHeight: 74 + Math.round(scale * 18), + border: isSelected ? '3px solid #7c3aed' : isInPath ? '3px solid #2f5aac' : isReachable ? '3px solid #16a34a' : '1px solid #94a3b8', + borderRadius: 12, + background: isSelected ? '#f3e8ff' : isInPath ? '#dbeafe' : isReachable ? '#f0fdf4' : `rgba(255,255,255,${0.82 + scale * 0.18})`, + boxShadow: isInPath || isSelected || isReachable ? '0 8px 24px rgba(30,64,175,.18)' : '0 3px 10px rgba(15,23,42,.08)', + opacity: isInPath || isSelected || isReachable ? 1 : 0.3, + padding: 12, + whiteSpace: 'pre-line' as const, + cursor: 'pointer', + fontWeight: isInPath || isSelected || isReachable ? 600 : 500, + }, + }; + }); + const maxEdgeCount = Math.max(1, ...graph.edges.map((edge) => edge.edgeCount)); + const edges = graph.edges.map((edge) => ({ + id: edgeId(edge), + source: edge.fromType, + target: edge.toType, + type: 'routed', + data: { path: layout.routes.get(edgeId(edge)) }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#475569', width: 14, height: 14 }, + interactionWidth: 24, + animated: false, + style: (() => { + const selected = selectedPath.some((candidate) => candidate.fromType === edge.fromType && candidate.toType === edge.toType && candidate.label === edge.label); + const reachable = reachableEdgeIds.has(edgeId(edge)); + const weight = 1.25 + 6 * Math.sqrt(edge.edgeCount / maxEdgeCount); + return { stroke: selected ? '#2563eb' : reachable ? '#16a34a' : '#64748b', strokeWidth: selected ? weight + 2 : reachable ? weight + 1 : weight, opacity: selected || reachable ? 1 : 0.12 + 0.24 * Math.sqrt(edge.edgeCount / maxEdgeCount) }; + })(), + })); + const graphIdentity = `${graph.nodes.map((node) => node.resourceType).sort().join(',')}|${graph.edges.map(edgeId).sort().join(',')}`; + return ( + <> +
+ + + { if (!disabled) onNodeSelect(node.id); }} + onEdgeClick={(_, edge) => { + if (disabled) return; + const candidate = graph.edges.find((item) => edgeId(item) === edge.id); + if (candidate) onEdgeSelect(candidate); + }} + onPaneClick={onPaneClick} + nodesConnectable={false} + minZoom={0.2} + maxZoom={2} + proOptions={{ hideAttribution: true }} + > + + + + +
+ Relationship list & keyboard controls +
+ {graph.edges.map((edge) => { + const selected = selectedPath.some((candidate) => candidate.fromType === edge.fromType && candidate.toType === edge.toType && candidate.label === edge.label); + const actionable = reachableEdgeIds.has(edgeId(edge)) || selected; + return ; + })} +
+
+
+ + ); +}; + +export const GuidedBuilder = ({ + organization, + project, + recipe, + explorer, + disabled, + recipeSource, + onRecipeChange, + onExplorerChange, + onPreview, + onRender, + preview, + previewStatus, + previewError, + previewOutput, + onRetryPreview, + onSaveDraft, + onMakeLive, +}: { + readonly organization: string; + readonly project: string; + readonly recipe: RecipeAuthoringDocument; + readonly explorer: ExplorerAuthoringDocument; + readonly disabled: boolean; + readonly recipeSource?: 'platform-default' | 'project-draft'; + readonly onRecipeChange: (document: RecipeAuthoringDocument) => void; + readonly onExplorerChange: (document: ExplorerAuthoringDocument) => void; + readonly onPreview: (output: string, recipe: RecipeAuthoringDocument) => void; + readonly onRender?: (output: string, recipe: RecipeAuthoringDocument) => void; + readonly preview?: RecipeDraftPreview; + readonly previewStatus?: 'idle' | 'loading' | 'ready' | 'error'; + readonly previewError?: string; + readonly previewOutput?: string; + readonly onRetryPreview?: () => void; + readonly onSaveDraft?: () => void; + readonly onMakeLive?: () => void; +}) => { + const currentOutput = selectedOutputOf(recipe); + const currentRoot = + typeof currentOutput?.rootResourceType === 'string' + ? currentOutput.rootResourceType + : ''; + const [projectMap, setProjectMap] = useState(() => + mapFromRecipe(recipe), + ); + const [semanticCatalog, setSemanticCatalog] = useState(null); + const [semanticCatalogState, setSemanticCatalogState] = useState<'loading' | 'ready' | 'empty' | 'unavailable'>('loading'); + const [recipeCandidates, setRecipeCandidates] = useState>([]); + const [recipeCandidateConnection, setRecipeCandidateConnection] = useState(); + const [recipeCandidateState, setRecipeCandidateState] = useState<'idle' | 'loading' | 'ready' | 'unavailable'>('idle'); + const [scanState, setScanState] = useState<'loading' | 'ready' | 'error'>('loading'); + const [selectedRoot, setSelectedRoot] = useState(currentRoot); + const [selectedOutputName, setSelectedOutputName] = useState(outputKey(currentOutput)); + const [selectedNodeType, setSelectedNodeType] = useState(currentRoot); + const [selectedFields, setSelectedFields] = useState>([]); + const [selectedFieldsByNode, setSelectedFieldsByNode] = useState>>({}); + const [selectedPath, setSelectedPath] = useState>([]); + const [inspectedEdge, setInspectedEdge] = useState(); + const [fieldSearch, setFieldSearch] = useState(''); + const [showTechnicalSource, setShowTechnicalSource] = useState(false); + // Column selection is a first-class workspace, not a transient modal. + // Keep it visible beside the graph on desktop; graph interactions simply + // change which node the panel is inspecting. + const [inspectorOpen, setInspectorOpen] = useState(true); + const [expandedPane, setExpandedPane] = useState<'graph' | 'columns'>(); + const [tableNameDraft, setTableNameDraft] = useState<{ readonly kind: 'new' | 'duplicate'; readonly value: string }>(); + const tableNameInputRef = useRef(null); + const [reviewOpen, setReviewOpen] = useState(false); + // Every ingested relationship belongs in the graph. The user can judge its + // relevance from the displayed link count instead of toggling a filter. + const showSparseData = true; + const [scanAttempt, setScanAttempt] = useState(0); + const initializedDefaultProject = useRef(''); + const hydratedSelection = useRef(''); + const hydratedCandidateSelection = useRef(''); + const selectedOutputIntent = useRef(undefined); + const [tableTitle, setTableTitle] = useState( + typeof currentOutput?.name === 'string' + ? titleFor(currentOutput.name) + : `${titleFor(currentRoot)} overview`, + ); + + useEffect(() => { + if (!expandedPane) return undefined; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setExpandedPane(undefined); + }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [expandedPane]); + + useEffect(() => { + if (!tableNameDraft) return; + tableNameInputRef.current?.focus(); + tableNameInputRef.current?.select(); + }, [tableNameDraft]); + + useEffect(() => { + const controller = new AbortController(); + setScanState('loading'); + // Loom's ingested graph/catalog identity is the established + // `-` dataset key. Recipe drafts remain scoped by + // Gecko as `/`; do not mix the two identities. + void scanFhirProjectMap(`${organization}-${project}`, controller.signal) + .then((nextMap) => { + setProjectMap(nextMap); + setScanState('ready'); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.name === 'AbortError') return; + setScanState('error'); + }); + return () => controller.abort(); + }, [organization, project, scanAttempt]); + + useEffect(() => { + const controller = new AbortController(); + setSemanticCatalogState('loading'); + const resourceTypes = projectMap.nodes.map((node) => node.resourceType).filter(Boolean); + if (resourceTypes.length === 0) return () => controller.abort(); + void Promise.allSettled(resourceTypes.map((resourceType) => + fetchSemanticConceptCatalog(`${organization}-${project}`, resourceType, controller.signal), + )) + .then((results) => { + const catalogs = results + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map((result) => result.value); + if (catalogs.length === 0) throw new Error('Semantic catalog unavailable for all resources'); + const resources = catalogs.flatMap((catalog) => catalog.resources); + const diagnostics = [ + ...catalogs.flatMap((catalog) => catalog.diagnostics), + ...(results.some((result) => result.status === 'rejected') + ? [{ severity: 'warning' as const, code: 'SEMANTIC_CATALOG_RESOURCE_UNAVAILABLE', message: 'Some resource concept catalogs were unavailable; technical fields remain available for those resources.' }] + : []), + ]; + const completenessStates = catalogs.map((catalog) => catalog.completeness?.state); + const merged = catalogs[0] ? { + ...catalogs[0], + resources, + diagnostics, + completeness: { + ...catalogs[0].completeness, + state: completenessStates.includes('partial') ? 'partial' : completenessStates.every((state) => state === 'empty') ? 'empty' : 'complete', + returnedResourceCount: resources.length, + returnedConceptCount: resources.reduce((count, resource) => count + resource.families.reduce((familyCount, family) => familyCount + family.concepts.length, 0), 0), + }, + } : null; + setSemanticCatalog(merged); + setSemanticCatalogState(semanticCatalogAvailability(merged)); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.name === 'AbortError') return; + setSemanticCatalog(null); + setSemanticCatalogState('unavailable'); + }); + return () => controller.abort(); + }, [organization, project, projectMap.nodes, scanAttempt]); + + const availableRoots = useMemo( + () => projectMap.nodes.map((node) => node.resourceType), + [projectMap.nodes], + ); + const selectedNode = projectMap.nodes.find( + (node) => node.resourceType === selectedRoot, + ); + const availableFields = useMemo( + () => { + const concepts = semanticFieldsFor(semanticCatalog, selectedRoot); + return concepts.length > 0 + ? concepts + : selectedNode ? dataFieldsFor(selectedNode.fields, selectedNode.resourceType) : []; + }, + [semanticCatalog, selectedNode, selectedRoot], + ); + + useEffect(() => { + if (!availableRoots.includes(selectedRoot)) { + setSelectedRoot(availableRoots[0] ?? ''); + setSelectedNodeType(availableRoots[0] ?? ''); + } + }, [availableRoots, selectedRoot]); + + useEffect(() => { + if (availableFields.length === 0) return; + setSelectedFields((current) => + current.length > 0 && current.every((field) => availableFields.some((item) => item.fieldRef === field)) + ? current + : defaultFields(availableFields), + ); + }, [availableFields]); + + + useEffect(() => { + setSelectedFieldsByNode((current) => ({ + ...current, + [selectedRoot]: selectedFields, + })); + }, [selectedFields, selectedRoot]); + + const existingOutputs = outputsOf(recipe); + const hasEditableOutput = existingOutputs.some((output) => { + if (typeof output.rootResourceType !== 'string' || !output.rootResourceType) return false; + return ['fields', 'traversals', 'catalogProjections', 'dynamicColumns', 'extensionColumns', 'pivots'] + .some((key) => Array.isArray(output[key]) && output[key].length > 0); + }); + const existingTabs = Array.isArray(explorer.tabs) + ? explorer.tabs.filter( + (tab): tab is Record => + Boolean(tab) && typeof tab === 'object' && !Array.isArray(tab), + ) + : []; + + const activeOutput = existingOutputs.find((output) => outputKey(output) === selectedOutputName) ?? existingOutputs[0]; + const activeOutputName = outputKey(activeOutput); + const candidateResourceType = selectedNodeType || selectedRoot; + const selectedOutputIsPending = Boolean( + selectedOutputName && + !existingOutputs.some((output) => outputKey(output) === selectedOutputName), + ); + + useEffect(() => { + const outputName = activeOutputName; + const nodePath = candidateNodePath(activeOutput, candidateResourceType); + if (!outputName || !nodePath) { + setRecipeCandidates([]); + setRecipeCandidateConnection(undefined); + setRecipeCandidateState('idle'); + return undefined; + } + const controller = new AbortController(); + setRecipeCandidateState('loading'); + void fetchRecipeColumnCandidates( + `${organization}-${project}`, + recipe, + outputName, + nodePath, + controller.signal, + ).then((connection) => { + setRecipeCandidates(connection.nodes); + setRecipeCandidateConnection(connection); + setRecipeCandidateState('ready'); + }).catch((error: unknown) => { + if (error instanceof Error && error.name === 'AbortError') return; + setRecipeCandidates([]); + setRecipeCandidateConnection(undefined); + setRecipeCandidateState('unavailable'); + }); + return () => controller.abort(); + }, [activeOutput, activeOutputName, candidateResourceType, organization, project, recipe]); + + useEffect(() => { + if (recipeCandidateState !== 'ready' || recipeCandidates.length === 0) return; + const selected = recipeCandidates.filter((candidate) => candidate.selected).map((candidate) => candidate.id).sort(); + const identity = `${activeOutputName}:${selected.join(',')}`; + if (hydratedCandidateSelection.current === identity) return; + hydratedCandidateSelection.current = identity; + if (candidateResourceType === selectedRoot) setSelectedFields(selected); + setSelectedFieldsByNode((current) => ({ ...current, [candidateResourceType]: selected })); + }, [activeOutputName, candidateResourceType, recipeCandidateState, recipeCandidates, selectedRoot]); + + useEffect(() => { + if (selectedOutputIsPending) return; + const outputName = outputKey(activeOutput); + const configuredRoot = typeof activeOutput?.rootResourceType === 'string' + ? activeOutput.rootResourceType + : ''; + const root = resolveResourceType(projectMap.nodes, configuredRoot) ?? configuredRoot; + if (!activeOutput || !outputName || !root || !availableRoots.includes(root)) return; + if (selectedOutputIntent.current && selectedOutputIntent.current !== outputName) return; + const semanticIdentity = semanticCatalog?.catalogId ?? semanticCatalog?.resources + .flatMap((resource) => resource.families.flatMap((family) => family.concepts.map((concept) => concept.id))) + .sort() + .join(',') ?? semanticCatalogState; + const hydrationKey = `${outputName}|${root}|${projectMap.edges.map(edgeId).sort().join(',')}|${semanticIdentity}`; + if (hydratedSelection.current === hydrationKey) return; + hydratedSelection.current = hydrationKey; + const hydrated = hydrateRecipeTraversal(activeOutput, projectMap, root, semanticCatalog); + const rootConceptSelections = Array.isArray(activeOutput.conceptSelections) + ? activeOutput.conceptSelections.map(asRecord) + : []; + const resolvedRootFields = Array.isArray(activeOutput.fields) + ? activeOutput.fields.map(asRecord).map((field) => { + const expr = asRecord(field.expr); + const path = typeof expr.select === 'string' ? expr.select.replace(/^root\./, '') : ''; + return recipeFieldRef(field, root, semanticCatalog, rootConceptSelections) || (projectMap.nodes.find((node) => node.resourceType === root)?.fields + .find((hint) => shortFieldPath(hint, root) === path)?.fieldRef ?? ''); + }).filter(Boolean) + : []; + // A previewable Loom output requires a root projection. If a persisted + // expression cannot be mapped back to the current picker generation, + // recover with the normal root recommendation rather than leaving a + // traversal that the primary action can never render. + const rootFields = resolvedRootFields.length > 0 + ? resolvedRootFields + : defaultFields( + semanticFieldsFor(semanticCatalog, root).length > 0 + ? semanticFieldsFor(semanticCatalog, root) + : (projectMap.nodes.find((node) => node.resourceType === root)?.fields ?? []), + root, + ); + setSelectedOutputName(outputName); + setSelectedRoot(root); + setSelectedNodeType(root); + setSelectedPath(hydrated.path); + setSelectedFields(rootFields); + setSelectedFieldsByNode((current) => ({ ...current, ...hydrated.fieldsByNode, [root]: rootFields })); + setTableTitle(titleFor(outputName)); + }, [activeOutput, availableRoots, projectMap, semanticCatalog, selectedOutputIsPending, selectedOutputName, selectedRoot]); + + const selectedNodeTypes = useMemo(() => { + const connected = new Set([selectedRoot]); + for (const edge of selectedPath) { + connected.add(edge.fromType); + connected.add(edge.toType); + } + return [...connected]; + }, [selectedPath, selectedRoot]); + const selectedQueryFieldCount = selectedNodeTypes.reduce( + (total, resourceType) => total + (resourceType === selectedRoot + ? selectedFields.length + : (selectedFieldsByNode[resourceType]?.length ?? 0)), + 0, + ); + const rootHasColumn = selectedFields.length > 0; + const renderDisabledReason = !rootHasColumn + ? `Choose at least one ${resourceLabel(selectedRoot)} row-start column before rendering.` + : undefined; + const inspectorType = selectedNodeType || selectedRoot; + const inspectorInQuery = selectedNodeTypes.includes(inspectorType); + const traversalEndpoint = selectedPath.at(-1)?.toType ?? selectedRoot; + const candidateEdge = useMemo( + () => inspectedEdge && + inspectedEdge.fromType === traversalEndpoint && + inspectedEdge.toType === inspectorType && + inspectedEdge.edgeCount > 0 && + (showSparseData || inspectedEdge.edgeCount >= 10) + ? inspectedEdge + : projectMap.edges + .filter((edge) => + edge.fromType === traversalEndpoint && + edge.toType === inspectorType && + edge.edgeCount > 0 && + (showSparseData || edge.edgeCount >= 10), + ) + .sort((left, right) => right.edgeCount - left.edgeCount)[0], + [inspectedEdge, inspectorType, projectMap.edges, showSparseData, traversalEndpoint], + ); + const reachableEdgeIds = useMemo( + () => new Set( + projectMap.edges + .filter((edge) => + edge.fromType === traversalEndpoint && + edge.edgeCount > 0 && + (showSparseData || edge.edgeCount >= 10), + ) + .map(edgeId), + ), + [projectMap.edges, showSparseData, traversalEndpoint], + ); + + const workspaceOutput = activeOutput; + const workspaceName = activeOutputName; + const updateWorkspace = (outputs: ReadonlyArray, tabs: ReadonlyArray>) => { + const nextRecipe = { ...recipe, recipeSchemaVersion: typeof recipe.recipeSchemaVersion === 'number' ? recipe.recipeSchemaVersion : 1, outputs: uniqueOutputNames(outputs) }; + onRecipeChange(nextRecipe); + onExplorerChange({ ...explorer, schemaVersion: 1, tabs: Array.from(tabs) }); + const previewName = activeOutputName && outputs.some((output) => output.name === activeOutputName) + ? activeOutputName + : outputKey(outputs[0]); + if (previewName) onPreview(previewName, nextRecipe); + }; + + const updateColumn = (index: number, key: 'name' | 'label' | 'visible', value: string | boolean) => { + const target = activeOutput; + if (!target) return; + const fields = Array.isArray(target.fields) ? target.fields.map(asRecord) : []; + const tab = existingTabs.find((candidate) => candidate.output === workspaceName); + const table = asRecord(tab?.table); + const columns = Array.isArray(table.columns) ? table.columns.map(asRecord) : []; + const previousColumn = asRecord(columns[index]); + const previousName = typeof previousColumn.field === 'string' ? previousColumn.field : ''; + const rootFieldIndex = fields.findIndex((field) => field.name === previousName); + const traversalPrefix = previousName.includes('__') ? previousName.slice(0, previousName.indexOf('__') + 2) : ''; + const renamedColumn = key === 'name' && typeof value === 'string' + ? traversalPrefix ? `${traversalPrefix}${value}` : value + : value; + if (key === 'name' && rootFieldIndex >= 0 && typeof value === 'string') { + fields[rootFieldIndex] = { ...fields[rootFieldIndex], name: value }; + } + const conceptSelections = Array.isArray(target.conceptSelections) + ? target.conceptSelections.map(asRecord) + : []; + if (key === 'label' && rootFieldIndex >= 0 && conceptSelections[rootFieldIndex]) { + conceptSelections[rootFieldIndex] = { ...conceptSelections[rootFieldIndex], label: value }; + } + const nextOutput = key === 'name' && rootFieldIndex < 0 && Array.isArray(target.traversals) + ? { ...target, fields, conceptSelections, traversals: renameTraversalField(target.traversals.map(asRecord), previousName, typeof value === 'string' ? value : '') } + : { ...target, fields, ...(conceptSelections.length > 0 ? { conceptSelections } : {}) }; + const nextOutputs = existingOutputs.map((candidate) => candidate === target ? nextOutput : candidate); + const outputName = typeof target.name === 'string' ? target.name : ''; + const nextTabs = existingTabs.map((tab) => { + if (tab.output !== outputName) return tab; + if (!columns[index]) return tab; + columns[index] = { ...columns[index], ...(key === 'label' ? { label: value } : key === 'visible' ? { visible: value } : { field: renamedColumn }) }; + return { ...tab, table: { ...table, columns } }; + }); + updateWorkspace(nextOutputs, nextTabs); + }; + + const reorderColumn = (sourceName: string, targetName: string) => { + const tab = existingTabs.find((candidate) => candidate.output === workspaceName); + if (!tab) return; + const table = asRecord(tab.table); + const columns = Array.isArray(table.columns) ? table.columns.map(asRecord) : []; + const sourceIndex = columns.findIndex((column) => column.field === sourceName); + const targetIndex = columns.findIndex((column) => column.field === targetName); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return; + const [column] = columns.splice(sourceIndex, 1); + columns.splice(sourceIndex < targetIndex ? targetIndex - 1 : targetIndex, 0, column); + updateWorkspace(existingOutputs, existingTabs.map((candidate) => candidate.output === workspaceName ? { ...candidate, table: { ...table, columns } } : candidate)); + }; + + const fieldsForResource = (resourceType: string): ReadonlyArray => { + if (resourceType === candidateResourceType && recipeCandidateState === 'ready' && recipeCandidates.length > 0) { + return recipeCandidates.map(candidateField); + } + const semanticFields = semanticFieldsFor(semanticCatalog, resourceType); + if (semanticFields.length > 0) return semanticFields; + const node = projectMap.nodes.find((candidate) => candidate.resourceType === resourceType); + return node?.fields ?? []; + }; + + useEffect(() => { + if (semanticCatalogState === 'loading') return; + setSelectedFieldsByNode((current) => { + let changed = false; + const next = Object.fromEntries(Object.entries(current).map(([resourceType, values]) => { + const valid = new Set(fieldsForResource(resourceType).map((field) => field.fieldRef)); + const filtered = values.filter((value) => valid.has(value)); + if (filtered.length !== values.length) changed = true; + return [resourceType, filtered]; + })); + return changed ? next : current; + }); + setSelectedFields((current) => { + const valid = new Set(fieldsForResource(selectedRoot).map((field) => field.fieldRef)); + const filtered = current.filter((value) => valid.has(value)); + return filtered.length === current.length ? current : filtered; + }); + // Keep persisted selections that are still in the active catalog, while + // dropping IDs from a stale generation or a resource-level fallback. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectMap.nodes, semanticCatalog, semanticCatalogState]); + + const semanticSelectionsFor = ( + resourceType: string, + selected: ReadonlyArray, + ): ReadonlyArray => semanticConceptsFor(semanticCatalog, resourceType) + .filter((concept) => selected.includes(concept.id)); + + const applyTable = ( + fieldsToUse = selectedFields, + rootToUse = selectedRoot, + titleToUse = tableTitle, + fieldMap = selectedFieldsByNode, + replaceActive = true, + pathToUse = selectedPath, + ) => { + const fieldsForRoot = fieldsForResource(rootToUse); + const fields = fieldsForRoot.filter((field) => + fieldsToUse.includes(field.fieldRef), + ); + const requestedOutputName = (titleToUse.trim() || outputNameFor(rootToUse)) + .replace(/[^A-Za-z0-9_]/g, '_') + .replace(/^_+|_+$/g, '') || outputNameFor(rootToUse); + const replacementIndex = replaceActive && activeOutput ? existingOutputs.indexOf(activeOutput) : -1; + const outputName = uniqueOutputName( + requestedOutputName, + existingOutputs.filter((_, index) => index !== replacementIndex), + ); + const aliases = new Map(); + const usedAliases = new Set(['root']); + const traversalByResource = new Map(); + const pending = [...pathToUse]; + const visitedTypes = new Set([rootToUse]); + while (pending.length > 0 && traversalByResource.size < 4) { + // Loom traversal declarations are outbound only. Never reverse a graph + // edge just because it happens to touch the selected root. + const index = pending.findIndex((edge) => visitedTypes.has(edge.fromType) && !visitedTypes.has(edge.toType)); + if (index < 0) break; + const edge = pending.splice(index, 1)[0]; + const target = edge.toType; + const alias = safeAlias(target, usedAliases); + aliases.set(target, alias); + const frontier = edge.fromType; + traversalByResource.set(target, { edge, parent: frontier, alias }); + visitedTypes.add(target); + } + const selectedTraversals = [...traversalByResource.entries()]; + const queryNodeTypes = [...new Set([rootToUse, ...pathToUse.flatMap((edge) => [edge.fromType, edge.toType])])]; + const relatedFieldsByResource = new Map>(); + const relatedColumns: ReadonlyArray<{ column: string; label: string }> = queryNodeTypes + .filter((resourceType) => resourceType !== rootToUse && aliases.has(resourceType)) + .flatMap((resourceType) => { + const fieldsForNode = fieldsForResource(resourceType); + const selected = fieldsForNode.filter((field) => (fieldMap[resourceType] ?? []).includes(field.fieldRef)); + const names = uniqueFieldNames(selected, resourceType); + return selected.map((field, index) => { + const path = shortFieldPath(field, resourceType); + const name = names[index]; + const concept = field.conceptId + ? semanticConceptsFor(semanticCatalog, resourceType).find((candidate) => candidate.id === field.conceptId) + : undefined; + return { + name, + expr: { select: path }, + column: `${aliases.get(resourceType) ?? resourceType}__${name}`, + label: field.label || titleFor(path), + ...(concept ? { conceptId: concept.id, ruleId: concept.ruleId } : {}), + }; + }); + }); + for (const resourceType of queryNodeTypes.filter((item) => item !== rootToUse && aliases.has(item))) { + const selected = (fieldMap[resourceType] ?? []); + const selectedFieldsForNode = fieldsForResource(resourceType).filter((field) => selected.includes(field.fieldRef)); + const names = uniqueFieldNames(selectedFieldsForNode, resourceType); + relatedFieldsByResource.set(resourceType, selectedFieldsForNode.map((field, index) => { + const path = shortFieldPath(field, resourceType); + return { name: names[index], expr: { select: `${aliases.get(resourceType) ?? resourceType}.${path}` } }; + })); + } + const rootNames = uniqueFieldNames(fields, rootToUse); + const rootFields = fields.map((field, index) => { + const path = shortFieldPath(field, rootToUse); + const concept = field.conceptId + ? semanticConceptsFor(semanticCatalog, rootToUse).find((candidate) => candidate.id === field.conceptId) + : undefined; + return { + name: rootNames[index], + expr: { select: `root.${path}` }, + ...(concept ? { conceptId: concept.id, ruleId: concept.ruleId, columnName: concept.column.name } : {}), + }; + }); + const { conceptSelections: _oldRootConceptSelections, ...activeOutputWithoutConceptSelections } = activeOutput ?? {}; + const outputDraft: RecipeOutput = { + ...activeOutputWithoutConceptSelections, + name: outputName, + rootResourceType: rootToUse, + rowGrain: rowGrainForResource(rootToUse), + fields: rootFields, + ...(semanticSelectionsFor(rootToUse, fieldsToUse).length > 0 + ? { + conceptSelections: conceptSelectionsFor(semanticCatalog, rootToUse, fieldsToUse), + } + : {}), + traversals: selectedTraversals.filter(([, item]) => item.parent === rootToUse).map(([resourceType, traversal]) => { + const existingRootTraversals = Array.isArray(activeOutput?.traversals) ? activeOutput.traversals.map(asRecord) : []; + const buildTraversal = (type: string, item: { edge: FhirTraversalHint; parent?: string; alias: string }, existing?: Record): Record => { + const { conceptSelections: _oldConceptSelections, ...existingWithoutConceptSelections } = existing ?? {}; + const traversalDraft: Record = { + ...existingWithoutConceptSelections, + // `name` is the exact populated Loom edge label. It is not a + // display label and must not be decorated with the target type. + name: item.edge.label, + toResourceType: type, + alias: item.alias, + matchMode: 'OPTIONAL', + fields: Array.from(relatedFieldsByResource.get(type) ?? []), + ...(semanticSelectionsFor(type, fieldMap[type] ?? []).length > 0 + ? { + conceptSelections: conceptSelectionsFor(semanticCatalog, type, fieldMap[type] ?? []), + } + : {}), + traversals: selectedTraversals.filter(([, child]) => child.parent === type).map(([childType, child]) => { + const oldChild = Array.isArray(existing?.traversals) ? existing.traversals.map(asRecord).find((candidate) => candidate.name === child.edge.label && candidate.toResourceType === childType) : undefined; + return buildTraversal(childType, child, oldChild); + }), + }; + return type === candidateResourceType && recipeCandidateState === 'ready' + ? selectedNativeCandidates(traversalDraft, recipeCandidates, fieldMap[type] ?? []) + : traversalDraft; + }; + const oldTraversal = existingRootTraversals.find((candidate) => candidate.name === traversal.edge.label && candidate.toResourceType === resourceType); + return buildTraversal(resourceType, traversal, oldTraversal); + }), + }; + // Recipe-aware candidates are the primary selection contract. They write + // the exact native family declarations Loom resolves; conceptSelections + // remain only for compatibility while a server without this endpoint is + // still in use. + const output: RecipeOutput = rootToUse === candidateResourceType && recipeCandidateState === 'ready' + ? selectedNativeCandidates(outputDraft, recipeCandidates, fieldsToUse) + : outputDraft; + const nextRecipe: RecipeAuthoringDocument = { + ...recipe, + recipeSchemaVersion: + typeof recipe.recipeSchemaVersion === 'number' + ? recipe.recipeSchemaVersion + : 1, + outputs: uniqueOutputNames(replacementIndex < 0 + ? [...existingOutputs, output] + : existingOutputs.map((candidate, index) => index === replacementIndex ? output : candidate)), + }; + onRecipeChange(nextRecipe); + setSelectedOutputName(outputName); + onExplorerChange({ + ...explorer, + schemaVersion: 1, + tabs: [ + ...(() => { + const nextTab = { + id: outputId(outputName), + title: titleToUse.trim() || titleFor(outputName), + output: outputName, + table: { + columns: [...fields.map((field, index) => { + const path = shortFieldPath(field, rootToUse); + return { + field: rootNames[index], + label: field.label || titleFor(path), + visible: true, + }; + }), ...relatedColumns.map((field) => ({ field: field.column, label: field.label, visible: true }))], + }, + }; + const replacementIndex = replaceActive && activeOutputName ? existingTabs.findIndex((tab) => tab.output === activeOutputName) : -1; + if (replacementIndex < 0) return [...existingTabs, nextTab]; + return existingTabs.map((tab, index) => index === replacementIndex ? nextTab : tab); + })(), + ], + }); + onPreview(outputName, nextRecipe); + return { outputName, recipe: nextRecipe }; + }; + + const removeTraversalStep = (stepIndex: number) => { + const nextPath = selectedPath.slice(0, stepIndex); + const retainedTypes = new Set([ + selectedRoot, + ...nextPath.map((edge) => edge.toType), + ]); + const nextFieldMap = Object.fromEntries( + Object.entries(selectedFieldsByNode).filter(([resourceType]) => + retainedTypes.has(resourceType), + ), + ); + const nextEndpoint = nextPath.at(-1)?.toType ?? selectedRoot; + setSelectedPath(nextPath); + setSelectedFieldsByNode(nextFieldMap); + setSelectedNodeType(nextEndpoint); + setInspectorOpen(true); + const rendered = applyTable( + selectedFields, + selectedRoot, + tableTitle, + nextFieldMap, + true, + nextPath, + ); + if (rendered) onRender?.(rendered.outputName, rendered.recipe); + }; + + const renderCurrentTable = () => { + if (!selectedRoot || selectedFields.length === 0) return; + applyTable( + selectedFields, + selectedRoot, + tableTitle, + selectedFieldsByNode, + true, + selectedPath, + ); + setReviewOpen(true); + }; + + useEffect(() => { + const projectKey = `${organization}/${project}`; + if ( + scanState !== 'ready' || + semanticCatalogState === 'loading' || + !recipeSource || + hasEditableOutput || + initializedDefaultProject.current === projectKey + ) return; + const candidates = projectMap.nodes + .filter((node) => node.fields.length > 0) + .sort((left, right) => { + if (left.resourceType === 'Patient') return -1; + if (right.resourceType === 'Patient') return 1; + return (right.documentCount ?? 0) - (left.documentCount ?? 0); + }); + const suggestedRoot = candidates[0]; + if (!suggestedRoot) return; + + initializedDefaultProject.current = projectKey; + const suggestedFields = defaultFields(fieldsForResource(suggestedRoot.resourceType), suggestedRoot.resourceType); + const fieldMap = { [suggestedRoot.resourceType]: suggestedFields }; + setSelectedRoot(suggestedRoot.resourceType); + setSelectedNodeType(suggestedRoot.resourceType); + setSelectedPath([]); + setSelectedFields(suggestedFields); + setSelectedFieldsByNode(fieldMap); + setTableTitle('Default Explorer'); + applyTable( + suggestedFields, + suggestedRoot.resourceType, + 'Default Explorer', + fieldMap, + existingOutputs.length > 0, + [], + ); + // The one-shot project guard intentionally keeps user-created blank drafts + // from being repopulated after the initial suggestion. + // eslint-disable-next-line reactHooks/exhaustive-deps + }, [existingOutputs.length, hasEditableOutput, organization, project, projectMap, recipeSource, scanState, semanticCatalogState]); + + const selectWorkspaceOutput = (nextOutputName: string) => { + const candidate = existingOutputs.find((output) => outputKey(output) === nextOutputName); + if (!candidate) return; + selectedOutputIntent.current = nextOutputName; + setSelectedOutputName(nextOutputName); + const nextRoot = typeof candidate.rootResourceType === 'string' ? candidate.rootResourceType : selectedRoot; + setSelectedRoot(nextRoot); + setSelectedNodeType(nextRoot); + const hydrated = hydrateRecipeTraversal(candidate, projectMap, nextRoot, semanticCatalog); + setSelectedPath(hydrated.path); + setSelectedFieldsByNode((current) => ({ ...current, ...hydrated.fieldsByNode })); + const conceptSelections = Array.isArray(candidate.conceptSelections) ? candidate.conceptSelections.map(asRecord) : []; + const nextFields = Array.isArray(candidate.fields) ? candidate.fields.map(asRecord).map((field) => { + const expr = asRecord(field.expr); + const semanticRef = recipeFieldRef(field, nextRoot, semanticCatalog, conceptSelections); + return semanticRef || (typeof expr.select === 'string' ? `${nextRoot}.${expr.select.replace(/^root\./, '')}` : ''); + }).filter(Boolean) : []; + setSelectedFields(nextFields); + setSelectedFieldsByNode((current) => ({ ...current, [nextRoot]: nextFields })); + setTableTitle(typeof candidate.name === 'string' ? titleFor(candidate.name) : tableTitle); + }; + + const submitTableNameDraft = () => { + const draft = tableNameDraft; + const title = draft?.value.trim(); + setTableNameDraft(undefined); + if (!draft || !title) return; + if (draft.kind === 'duplicate') { + if (!workspaceOutput || typeof workspaceOutput.name !== 'string') return; + const copyName = uniqueOutputName( + title.replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '') || `${workspaceOutput.name}_copy`, + existingOutputs, + ); + const sourceTab = existingTabs.find((tab) => tab.output === workspaceOutput.name); + setSelectedOutputName(copyName); + updateWorkspace([...existingOutputs, { ...workspaceOutput, name: copyName }], [...existingTabs, { ...(sourceTab ?? {}), id: outputId(copyName), title, output: copyName }]); + return; + } + const root = selectedRoot || availableRoots[0]; + if (!root) return; + const node = projectMap.nodes.find((candidate) => candidate.resourceType === root); + const fields = defaultFields(node?.fields ?? [], root); + setSelectedRoot(root); + setSelectedNodeType(root); + setSelectedPath([]); + setSelectedFields(fields); + setTableTitle(title); + applyTable(fields, root, title, { ...selectedFieldsByNode, [root]: fields }, false, []); + }; + + return ( +
+
+
+ + + + + { + if (tableNameDraft) setTableNameDraft(undefined); + else applyTable(selectedFields, selectedRoot, tableTitle); + }} + onChange={(event) => tableNameDraft + ? setTableNameDraft({ ...tableNameDraft, value: event.currentTarget.value }) + : setTableTitle(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') tableNameDraft ? submitTableNameDraft() : applyTable(selectedFields, selectedRoot, tableTitle); + if (event.key === 'Escape' && tableNameDraft) setTableNameDraft(undefined); + }} + placeholder={tableNameDraft?.kind === 'new' ? 'New table name' : undefined} + style={{ width: `${Math.max(14, Math.min(40, (tableNameDraft?.value ?? tableTitle).length + 3))}ch` }} + value={tableNameDraft?.value ?? tableTitle} + /> + + + {onSaveDraft && } + {onMakeLive && } +
+
+ +
+
+
{ + if (expandedPane === 'graph' || (event.target as Element).closest('button, input, label')) return; + setExpandedPane('graph'); + }}> +
+

Explore the populated dataset

+
+
{expandedPane === 'graph' && }
+ {scanState === 'error' && } +
+ {availableRoots.length > 0 ? <> + +
{ + const node = projectMap.nodes.find((candidate) => candidate.resourceType === resourceType); + setSelectedFieldsByNode((current) => current[resourceType] + ? current + : { ...current, [resourceType]: defaultFields(node?.fields ?? [], resourceType) }); + setSelectedNodeType(resourceType); + setInspectedEdge(undefined); + setInspectorOpen(true); + }} onEdgeSelect={(edge) => { + const node = projectMap.nodes.find((candidate) => candidate.resourceType === edge.toType); + setSelectedFieldsByNode((current) => current[edge.toType] + ? current + : { ...current, [edge.toType]: defaultFields(node?.fields ?? [], edge.toType) }); + setSelectedNodeType(edge.toType); + setInspectedEdge(edge); + setInspectorOpen(true); + }} onPaneClick={() => { if (expandedPane !== 'graph') setExpandedPane('graph'); }} />
+ :

No populated FHIR resources were found in this project yet. The graph will become available when populated data is present.

} +
+ + {inspectorOpen && } +
+ + {reviewOpen &&
+ {(() => { + const tab = existingTabs.find((candidate) => candidate.output === workspaceName); + const rawColumns: JSONValue[] = tab && Array.isArray(asRecord(tab.table).columns) ? asRecord(tab.table).columns as JSONValue[] : []; + const configuredColumns = rawColumns.map(asRecord).map((column, index) => ({ name: typeof column.field === 'string' ? column.field : undefined, field: typeof column.field === 'string' ? column.field : undefined, label: typeof column.label === 'string' ? column.label : undefined, visible: column.visible !== false, order: index })); + const columnIndex = (sourceName: string) => rawColumns.findIndex((column) => asRecord(column).field === sourceName); + const hiddenColumns = configuredColumns.filter((column) => column.visible === false && column.field); + return <> + {hiddenColumns.length > 0 &&
Hidden columns{hiddenColumns.map((column) => )}
} + updateColumn(columnIndex(sourceName), 'label', label)} + onColumnReorder={reorderColumn} + onColumnHide={(sourceName) => updateColumn(columnIndex(sourceName), 'visible', false)} + selectedPathSummary={[selectedRoot, ...selectedPath.map((edge) => edge.toType)]} + />; + })()} +
} +
+ ); +}; diff --git a/packages/frontend/src/features/ExplorerBuilder/guided/GuidedBuilder.unit.test.tsx b/packages/frontend/src/features/ExplorerBuilder/guided/GuidedBuilder.unit.test.tsx new file mode 100644 index 00000000..cfce32e8 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/guided/GuidedBuilder.unit.test.tsx @@ -0,0 +1,454 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { GuidedBuilder, rowGrainForResource } from './GuidedBuilder'; + +describe('rowGrainForResource', () => { + it.each([ + ['Patient', 'patient'], + ['DocumentReference', 'file'], + ['ResearchSubject', 'study_enrollment'], + ['ResearchStudy', 'resource'], + ['MedicationAdministration', 'resource'], + ])('maps %s to Loom grain %s', (resourceType, expected) => { + expect(rowGrainForResource(resourceType)).toBe(expected); + }); +}); + +jest.mock('./fhirProjectMap', () => ({ + scanFhirProjectMap: jest.fn().mockResolvedValue({ + nodes: [ + { + resourceType: 'Patient', + documentCount: 12, + fields: [ + { + fieldRef: 'Patient.id', + label: 'Patient ID', + path: 'id', + selector: { valuePath: 'id' }, + }, + { + fieldRef: 'Patient.gender', + label: 'Administrative gender', + path: 'gender', + selector: { valuePath: 'gender' }, + }, + { + fieldRef: 'Patient.name', + label: 'Name', + path: 'name', + selector: { valuePath: 'name' }, + }, + { + fieldRef: 'Patient.name.family', + label: 'Family name', + path: 'name.family', + selector: { valuePath: 'name.family' }, + }, + ], + traversals: [], + }, + { + resourceType: 'Condition', + documentCount: 8, + fields: [ + { + fieldRef: 'Condition.code', + label: 'Condition code', + path: 'code', + selector: { sourcePath: 'Condition', valuePath: 'code' }, + }, + ], + traversals: [], + }, + { + resourceType: 'Specimen', + documentCount: 3, + fields: [ + { + fieldRef: 'Specimen.id', + label: 'Specimen ID', + path: 'id', + selector: { valuePath: 'id' }, + }, + ], + traversals: [], + }, + ], + edges: [ + { fromType: 'Patient', label: 'subject_Condition', toType: 'Condition', edgeCount: 40 }, + { fromType: 'Condition', label: 'subject_Specimen', toType: 'Specimen', edgeCount: 20 }, + { fromType: 'Specimen', label: 'subject_Patient', toType: 'Patient', edgeCount: 1 }, + ], + }), +})); + +describe('GuidedBuilder', () => { + const openNodeInspector = async (resourceType: string) => { + await screen.findAllByText(new RegExp(resourceType, 'i')); + const node = document.querySelector(`.react-flow__node[data-id="${resourceType}"]`); + expect(node).not.toBeNull(); + fireEvent.click(node!); + }; + const renderTable = () => { + fireEvent.click(screen.getAllByRole('button', { name: /^render table/i })[0]); + }; + + it('turns steward choices into a recipe and Explorer table without exposing JSON', async () => { + const onRecipeChange = jest.fn(); + const onExplorerChange = jest.fn(); + const onPreview = jest.fn(); + render( + , + ); + + await openNodeInspector('Patient'); + await waitFor(() => + expect(screen.getByRole('checkbox', { name: /patient id/i })).toBeChecked(), + ); + await waitFor(() => expect(onRecipeChange).toHaveBeenCalledWith( + expect.objectContaining({ + outputs: expect.arrayContaining([ + expect.objectContaining({ name: 'Default_Explorer', rootResourceType: 'Patient' }), + ]), + }), + )); + expect(onExplorerChange).toHaveBeenCalledWith( + expect.objectContaining({ + tabs: expect.arrayContaining([ + expect.objectContaining({ title: 'Default Explorer', output: 'Default_Explorer' }), + ]), + }), + ); + renderTable(); + expect(screen.getByRole('region', { name: /rendered table/i })).toBeInTheDocument(); + + expect(onRecipeChange).toHaveBeenCalledWith( + expect.objectContaining({ + recipeSchemaVersion: 1, + outputs: expect.arrayContaining([ + expect.objectContaining({ + rootResourceType: 'Patient', + fields: expect.arrayContaining([ + expect.objectContaining({ + name: 'id', + expr: { select: 'root.id' }, + }), + ]), + }), + ]), + }), + ); + expect(onExplorerChange).toHaveBeenCalledWith( + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + table: expect.objectContaining({ + columns: expect.arrayContaining([ + expect.objectContaining({ field: 'id', visible: true }), + ]), + }), + }), + ], + }), + ); + expect(onPreview).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ outputs: expect.any(Array) }), + ); + expect(screen.queryByLabelText(/advanced json/i)).not.toBeInTheDocument(); + }); + + it('offers leaf data fields while explaining hidden structural containers', async () => { + render( + , + ); + + await openNodeInspector('Patient'); + await waitFor(() => expect(screen.getByRole('checkbox', { name: /family name/i })).toBeInTheDocument()); + expect(screen.queryByRole('checkbox', { name: /^name$/i })).not.toBeInTheDocument(); + expect(screen.getByText(/1 structural field hidden/i)).toBeInTheDocument(); + expect(screen.getByText(/12 populated records/i)).toBeInTheDocument(); + }); + + it('uses the complete catalog path and exact outbound edge labels for nested traversals', async () => { + const onRecipeChange = jest.fn(); + const onExplorerChange = jest.fn(); + render( + , + ); + + await openNodeInspector('Patient'); + await waitFor(() => expect(screen.getByRole('checkbox', { name: /patient id/i })).toBeChecked()); + await openNodeInspector('Condition'); + await waitFor(() => expect(screen.getByRole('checkbox', { name: /condition code/i })).toBeInTheDocument()); + const conditionCode = screen.getByRole('checkbox', { name: /condition code/i }) as HTMLInputElement; + if (!conditionCode.checked) fireEvent.click(conditionCode); + fireEvent.click(screen.getByRole('button', { name: /add diagnoses and conditions to traversal/i })); + await openNodeInspector('Specimen'); + fireEvent.click(await screen.findByRole('button', { name: /add biospecimens to traversal/i })); + await openNodeInspector('Condition'); + renderTable(); + + const recipe = onRecipeChange.mock.calls.at(-1)?.[0]; + const rootTraversal = recipe.outputs[0].traversals[0]; + expect(rootTraversal).toEqual(expect.objectContaining({ + name: 'subject_Condition', + toResourceType: 'Condition', + alias: 'condition', + matchMode: 'OPTIONAL', + })); + expect(rootTraversal.fields).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'code', expr: { select: 'condition.code' } }), + ])); + expect(rootTraversal.traversals[0]).toEqual(expect.objectContaining({ + name: 'subject_Specimen', + toResourceType: 'Specimen', + alias: 'specimen', + })); + const explorer = onExplorerChange.mock.calls.at(-1)?.[0]; + expect(explorer.tabs[0].table.columns).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: 'condition__code', visible: true }), + ])); + expect(screen.getByRole('heading', { name: /rendered table/i })).toBeInTheDocument(); + expect(screen.queryByText('Patient ID')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Explorer sample preview')).toBeInTheDocument(); + }); + + it('previews a reachable dataset before explicitly locking it into a removable traversal', async () => { + const onRecipeChange = jest.fn(); + render(); + + await waitFor(() => expect(onRecipeChange).toHaveBeenCalled()); + onRecipeChange.mockClear(); + await openNodeInspector('Condition'); + expect(screen.getByText(/available next step/i)).toBeInTheDocument(); + expect(onRecipeChange).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /add diagnoses and conditions to traversal/i })); + await waitFor(() => expect(onRecipeChange).toHaveBeenCalledWith( + expect.objectContaining({ + outputs: expect.arrayContaining([ + expect.objectContaining({ + traversals: expect.arrayContaining([ + expect.objectContaining({ toResourceType: 'Condition' }), + ]), + }), + ]), + }), + )); + expect(screen.getByRole('navigation', { name: /locked traversal/i })).toHaveTextContent(/people.*diagnoses and conditions/i); + + fireEvent.click(screen.getByRole('button', { name: /remove diagnoses and conditions and following traversal steps/i })); + await waitFor(() => { + const document = onRecipeChange.mock.calls.at(-1)?.[0]; + expect(document.outputs[0].traversals).toEqual([]); + }); + }); + + it('replaces and edits the active output instead of mutating the first table', async () => { + const onRecipeChange = jest.fn(); + const onExplorerChange = jest.fn(); + const recipe = { + recipeSchemaVersion: 1, + outputs: [ + { name: 'First', rootResourceType: 'Patient', rowGrain: 'patient', fields: [{ name: 'id', expr: { select: 'root.id' } }] }, + { name: 'Second', rootResourceType: 'Patient', rowGrain: 'patient', fields: [{ name: 'id', expr: { select: 'root.id' } }] }, + ], + }; + const explorer = { + schemaVersion: 1, + tabs: [ + { id: 'first', title: 'First', output: 'First', table: { columns: [{ field: 'id', label: 'ID', visible: true }] } }, + { id: 'second', title: 'Second', output: 'Second', table: { columns: [{ field: 'id', label: 'ID', visible: true }] } }, + ], + }; + const { rerender } = render(); + await waitFor(() => expect(screen.getAllByRole('button', { name: /second/i }).length).toBeGreaterThan(0)); + fireEvent.click(screen.getAllByRole('button', { name: /second/i })[0]); + await openNodeInspector('Patient'); + const title = screen.getByLabelText(/what should this table be called/i) as HTMLInputElement; + fireEvent.change(title, { target: { value: 'Renamed' } }); + fireEvent.blur(title); + const renamed = onRecipeChange.mock.calls.at(-1)?.[0]; + expect(renamed.outputs.map((output: { name: string }) => output.name)).toEqual(['First', 'Renamed']); + const renamedExplorer = onExplorerChange.mock.calls.at(-1)?.[0]; + expect(renamedExplorer.tabs.map((tab: { output: string }) => tab.output)).toEqual(['First', 'Renamed']); + rerender(); + + fireEvent.click(screen.getAllByRole('button', { name: /^duplicate$/i })[0]); + const duplicatedRecipe = onRecipeChange.mock.calls.at(-1)?.[0]; + const duplicatedExplorerDocument = onExplorerChange.mock.calls.at(-1)?.[0]; + rerender(); + await waitFor(() => expect(screen.getAllByRole('button', { name: /renamed copy/i }).length).toBeGreaterThan(0)); + const duplicatedExplorer = onExplorerChange.mock.calls.at(-1)?.[0]; + expect(duplicatedExplorer.tabs.find((tab: { output: string }) => tab.output === 'Renamed_copy').table.columns).toEqual( + expect.arrayContaining([expect.objectContaining({ field: 'id' })]), + ); + fireEvent.click(screen.getAllByRole('button', { name: /^delete$/i })[0]); + const deleted = onRecipeChange.mock.calls.at(-1)?.[0]; + expect(deleted.outputs.map((output: { name: string }) => output.name)).toEqual(['First', 'Renamed']); + }); + + it('hydrates an incomplete placeholder only after the recipe request has resolved', async () => { + const onRecipeChange = jest.fn(); + const onExplorerChange = jest.fn(); + const { rerender } = render( + , + ); + await waitFor(() => expect(screen.getByText(/resource types found/i)).toBeInTheDocument()); + expect(onRecipeChange).not.toHaveBeenCalled(); + + rerender( + , + ); + + await waitFor(() => expect(onRecipeChange).toHaveBeenCalled()); + const hydrated = onRecipeChange.mock.calls.at(-1)?.[0]; + expect(hydrated.outputs).toHaveLength(1); + expect(hydrated.outputs[0]).toEqual(expect.objectContaining({ + name: 'Default_Explorer', + rootResourceType: 'Patient', + fields: expect.arrayContaining([expect.objectContaining({ name: 'id' })]), + })); + }); + + it('edits and reorders column headers directly on rendered sample rows', async () => { + const onRecipeChange = jest.fn(); + const onExplorerChange = jest.fn(); + const recipe = { + recipeSchemaVersion: 1, + outputs: [{ + name: 'People', + rootResourceType: 'Patient', + rowGrain: 'patient', + fields: [{ name: 'id', expr: { select: 'root.id' } }], + traversals: [{ + name: 'subject_Condition', + toResourceType: 'Condition', + alias: 'condition', + matchMode: 'OPTIONAL', + fields: [{ name: 'code', expr: { select: 'condition.code' } }], + }], + }], + }; + const explorer = { + schemaVersion: 1, + tabs: [{ + id: 'people', + title: 'People', + output: 'People', + table: { columns: [ + { field: 'id', label: 'ID', visible: true }, + { field: 'condition__code', label: 'Condition', visible: true }, + ] }, + }], + }; + const preview = { + output: 'People', + columns: [{ name: 'id' }, { name: 'condition__code' }] as never, + rows: [{ id: 'patient-1', condition__code: 'C50.9' }], + rowCount: 1, + validation: { diagnostics: [], outputs: [] }, + }; + const props = { disabled: false, onExplorerChange, onPreview: jest.fn(), onRecipeChange, organization: 'acme', project: 'study', preview, previewOutput: 'People', previewStatus: 'ready' as const }; + const { rerender } = render(); + + await waitFor(() => expect(screen.getByRole('navigation', { name: /locked traversal/i })).toHaveTextContent(/people.*diagnoses and conditions/i)); + renderTable(); + expect(await screen.findByText('patient-1')).toBeInTheDocument(); + expect(screen.getByText('C50.9')).toBeInTheDocument(); + const conditionHeader = screen.getByRole('textbox', { name: /column condition__code display name/i }); + fireEvent.change(conditionHeader, { target: { value: 'Diagnosis' } }); + const labeledExplorer = onExplorerChange.mock.calls.at(-1)?.[0]; + expect(labeledExplorer.tabs[0].table.columns[1].label).toBe('Diagnosis'); + rerender(); + fireEvent.click(screen.getByRole('button', { name: /move diagnosis left/i })); + const reorderedExplorer = onExplorerChange.mock.calls.at(-1)?.[0]; + rerender(); + + expect(reorderedExplorer.tabs[0].table.columns[0].field).toBe('condition__code'); + expect(screen.getByRole('textbox', { name: /column condition__code display name/i })).toHaveValue('Diagnosis'); + }); + + it('states clearly when the live project scan has no populated resources', async () => { + const scan = (jest.requireMock('./fhirProjectMap') as { scanFhirProjectMap: jest.Mock }).scanFhirProjectMap; + scan.mockResolvedValueOnce({ nodes: [], edges: [] }); + render(); + expect(await screen.findByText(/No populated FHIR resources were found/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new table/i })).toBeDisabled(); + }); + + it('uses the graph itself to choose the row root without a separate rows selector', async () => { + const onRecipeChange = jest.fn(); + render(); + + await openNodeInspector('Condition'); + expect(screen.queryByLabelText(/rows represent/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /start each row with diagnoses/i })); + + await waitFor(() => expect(onRecipeChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + outputs: expect.arrayContaining([ + expect.objectContaining({ rootResourceType: 'Condition' }), + ]), + }), + )); + }); + + it('keeps sparse relationships out of the primary graph until requested', async () => { + render(); + + await screen.findByText(/resource types found/i); + expect(screen.queryByRole('button', { name: /subject_Patient.*1/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('checkbox', { name: /show sparse relationships/i })); + expect(await screen.findByRole('button', { name: /subject_Patient.*1/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/src/features/ExplorerBuilder/guided/fhirProjectMap.ts b/packages/frontend/src/features/ExplorerBuilder/guided/fhirProjectMap.ts new file mode 100644 index 00000000..6d1123af --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/guided/fhirProjectMap.ts @@ -0,0 +1,118 @@ +import { GEN3_LOOM_API, fetchGraphQL, type RecipeColumnCandidate } from '@gen3/core'; + +export interface FhirFieldHint { + readonly fieldRef: string; + readonly label?: string; + readonly path?: string; + /** Populated only when this is a v2 semantic concept, never inferred for fallback fields. */ + readonly conceptId?: string; + readonly ruleId?: string; + readonly columnName?: string; + readonly selector?: { + readonly sourcePath?: string; + readonly valuePath?: string; + }; + /** Present only for the recipe-aware Loom candidate API. */ + readonly recipeCandidate?: RecipeColumnCandidate; +} + +export interface FhirTraversalHint { + readonly fromType: string; + readonly label: string; + readonly toType: string; + readonly edgeCount: number; +} + +export interface FhirResourceHint { + readonly resourceType: string; + /** Best non-overcounting resource count exposed by Loom's catalog scan. */ + readonly documentCount?: number; + readonly fields: ReadonlyArray; + readonly traversals: ReadonlyArray; +} + +interface ProjectMapResult { + readonly resources?: ReadonlyArray; + readonly relationships?: ReadonlyArray; +} + +interface ProjectMapResponse { + readonly dataframeBuilderProjectMap: ProjectMapResult; +} + +export interface FhirProjectMap { + readonly nodes: ReadonlyArray; + readonly edges: ReadonlyArray; +} + +const projectMapQuery = `query BuilderProjectMap($input: DataframeBuilderProjectMapInput!) { + dataframeBuilderProjectMap(input: $input) { + project + sourceGeneration + resources { + resourceType + documentCount + fields { fieldRef label path selector { sourcePath valuePath } } + traversals { fromType label toType edgeCount } + } + relationships { fromType label toType edgeCount } + } +}`; + +const endpoint = `${GEN3_LOOM_API}/graphql/graph`; + +/** + * Reads Loom's populated-field introspection surface. This is deliberately a + * read-only scan: it never guesses a FHIR field or relationship and it never + * changes a project while the steward is exploring it. + */ +export const scanFhirProjectMap = async ( + project: string, + signal?: AbortSignal, +): Promise => { + const response = await fetchGraphQL( + { + query: projectMapQuery, + variables: { input: { project, includePivotOnlyFields: false } }, + }, + { endpoint, signal }, + ); + const map = response.dataframeBuilderProjectMap ?? {}; + const nodeByType = new Map(); + const edgeByKey = new Map(); + for (const node of map.resources ?? []) { + // Relationship catalogs also contain path-segment artifacts such as + // "subject", "parent", and "collection". They are graph plumbing, not + // datasets a steward can select. A real graph node must have records or + // selectable populated fields. + if ((node.documentCount ?? 0) > 0 || node.fields.length > 0) { + nodeByType.set(node.resourceType, node); + } + for (const edge of node.traversals) { + edgeByKey.set(`${edge.fromType}/${edge.label}/${edge.toType}`, edge); + } + } + for (const edge of map.relationships ?? []) { + if (edge.edgeCount > 0) { + edgeByKey.set(`${edge.fromType}/${edge.label}/${edge.toType}`, edge); + } + } + + const edges = [...edgeByKey.values()].filter( + (edge) => nodeByType.has(edge.fromType) && nodeByType.has(edge.toType), + ); + const connectedTypes = new Set( + edges.flatMap((edge) => [edge.fromType, edge.toType]), + ); + + return { + nodes: [...nodeByType.values()].filter((node) => connectedTypes.has(node.resourceType)).sort((left, right) => + left.resourceType.localeCompare(right.resourceType), + ), + edges: edges.sort((left, right) => + `${left.fromType}/${left.toType}`.localeCompare( + `${right.fromType}/${right.toType}`, + ), + ), + }; +}; diff --git a/packages/frontend/src/features/ExplorerBuilder/guided/fhirProjectMap.unit.test.ts b/packages/frontend/src/features/ExplorerBuilder/guided/fhirProjectMap.unit.test.ts new file mode 100644 index 00000000..b388d862 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/guided/fhirProjectMap.unit.test.ts @@ -0,0 +1,44 @@ +import { fetchGraphQL } from '@gen3/core'; +import { scanFhirProjectMap } from './fhirProjectMap'; + +jest.mock('@gen3/core', () => ({ + GEN3_LOOM_API: '/loom', + fetchGraphQL: jest.fn(), +})); + +describe('scanFhirProjectMap', () => { + it('requests the scoped project and removes relationship-path artifacts', async () => { + (fetchGraphQL as jest.Mock).mockResolvedValueOnce({ + dataframeBuilderProjectMap: { + resources: [ + { resourceType: 'Patient', documentCount: 12, fields: [], traversals: [] }, + { resourceType: 'Specimen', documentCount: 3, fields: [], traversals: [] }, + { resourceType: 'Organization', documentCount: 2, fields: [{ fieldRef: 'Organization.id' }], traversals: [] }, + { resourceType: 'Parent', fields: [], traversals: [] }, + ], + relationships: [ + { fromType: 'Patient', label: 'subject_Specimen', toType: 'Specimen', edgeCount: 4 }, + { fromType: 'Specimen', label: 'parent', toType: 'Parent', edgeCount: 3 }, + ], + }, + }); + + const map = await scanFhirProjectMap('HTAN_INT/BForePC'); + + expect(fetchGraphQL).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + project: 'HTAN_INT/BForePC', + includePivotOnlyFields: false, + }, + }, + }), + expect.objectContaining({ endpoint: '/loom/graphql/graph' }), + ); + expect(map.nodes.map((node) => node.resourceType)).toEqual(['Patient', 'Specimen']); + expect(map.edges).toEqual([ + expect.objectContaining({ label: 'subject_Specimen', edgeCount: 4 }), + ]); + }); +}); diff --git a/packages/frontend/src/features/ExplorerBuilder/guided/graphLayout.ts b/packages/frontend/src/features/ExplorerBuilder/guided/graphLayout.ts new file mode 100644 index 00000000..68fa9e56 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/guided/graphLayout.ts @@ -0,0 +1,99 @@ +import ELK, { type ElkNode } from 'elkjs/lib/elk.bundled.js'; + +export interface GraphLayoutNode { + readonly id: string; + readonly width: number; + readonly height: number; +} + +export interface GraphLayoutEdge { + readonly id: string; + readonly source: string; + readonly target: string; +} + +export interface GraphLayoutResult { + readonly positions: ReadonlyMap; + readonly routes: ReadonlyMap; +} + +const elk = new ELK(); + +const routePath = ( + section: + | { + readonly startPoint: { readonly x: number; readonly y: number }; + readonly bendPoints?: ReadonlyArray<{ readonly x: number; readonly y: number }>; + readonly endPoint: { readonly x: number; readonly y: number }; + } + | undefined, +) => { + if (!section) return undefined; + const points = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]; + return points + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) + .join(' '); +}; + +/** + * Runs ELK's Sugiyama-style directed layout. The layered pipeline assigns + * ranks, reduces crossings, balances nodes within ranks, and routes edges + * orthogonally. Coordinates are deterministic for the same ordered graph. + */ +export const layoutDatasetGraph = async ( + nodes: ReadonlyArray, + edges: ReadonlyArray, +): Promise => { + if (nodes.length === 0) { + return { positions: new Map(), routes: new Map() }; + } + + const input: ElkNode = { + id: 'dataset-graph', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.aspectRatio': '2.2', + 'elk.padding': '[top=60,left=60,bottom=60,right=60]', + 'elk.spacing.nodeNode': '72', + 'elk.spacing.componentComponent': '110', + 'elk.layered.spacing.nodeNodeBetweenLayers': '170', + 'elk.layered.spacing.edgeNodeBetweenLayers': '70', + 'elk.layered.layering.strategy': 'NETWORK_SIMPLEX', + 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', + 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED', + 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + 'elk.layered.nodePlacement.favorStraightEdges': 'true', + 'elk.layered.cycleBreaking.strategy': 'GREEDY_MODEL_ORDER', + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + 'elk.separateConnectedComponents': 'true', + }, + children: nodes.map((node) => ({ + id: node.id, + width: node.width, + height: node.height, + })), + edges: edges.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })), + }; + const graph = await elk.layout(input); + + return { + positions: new Map( + (graph.children ?? []).map((node) => [ + node.id, + { x: node.x ?? 0, y: node.y ?? 0 }, + ]), + ), + routes: new Map( + (graph.edges ?? []).flatMap((edge) => { + const path = routePath(edge.sections?.[0]); + return path ? [[edge.id, path] as const] : []; + }), + ), + }; +}; diff --git a/packages/frontend/src/features/ExplorerBuilder/guided/graphLayout.unit.test.ts b/packages/frontend/src/features/ExplorerBuilder/guided/graphLayout.unit.test.ts new file mode 100644 index 00000000..83f2ed9e --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/guided/graphLayout.unit.test.ts @@ -0,0 +1,47 @@ +import { layoutDatasetGraph } from './graphLayout'; + +describe('layoutDatasetGraph', () => { + const nodes = [ + { id: 'Patient', width: 220, height: 90 }, + { id: 'Observation', width: 220, height: 90 }, + { id: 'Specimen', width: 200, height: 80 }, + { id: 'DocumentReference', width: 230, height: 90 }, + ]; + const edges = [ + { id: 'patient-observation', source: 'Patient', target: 'Observation' }, + { id: 'patient-specimen', source: 'Patient', target: 'Specimen' }, + { id: 'observation-file', source: 'Observation', target: 'DocumentReference' }, + { id: 'specimen-file', source: 'Specimen', target: 'DocumentReference' }, + ]; + + it('produces a deterministic left-to-right layered layout with routed edges', async () => { + const first = await layoutDatasetGraph(nodes, edges); + const second = await layoutDatasetGraph(nodes, edges); + + expect([...first.positions]).toEqual([...second.positions]); + expect(first.positions.get('Patient')!.x).toBeLessThan( + first.positions.get('Observation')!.x, + ); + expect(first.positions.get('Observation')!.x).toBeLessThan( + first.positions.get('DocumentReference')!.x, + ); + expect([...first.routes.keys()].sort()).toEqual( + edges.map((edge) => edge.id).sort(), + ); + + for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) { + const left = nodes[leftIndex]; + const right = nodes[rightIndex]; + const leftPosition = first.positions.get(left.id)!; + const rightPosition = first.positions.get(right.id)!; + const separated = + leftPosition.x + left.width <= rightPosition.x || + rightPosition.x + right.width <= leftPosition.x || + leftPosition.y + left.height <= rightPosition.y || + rightPosition.y + right.height <= leftPosition.y; + expect(separated).toBe(true); + } + } + }); +}); diff --git a/packages/frontend/src/features/ExplorerBuilder/guided/semanticConcepts.ts b/packages/frontend/src/features/ExplorerBuilder/guided/semanticConcepts.ts new file mode 100644 index 00000000..f703c1e8 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/guided/semanticConcepts.ts @@ -0,0 +1,152 @@ +import type { + FhirFieldHint, + FhirResourceHint, +} from './fhirProjectMap'; +import type { + SemanticConcept, + SemanticConceptCatalog, +} from '@gen3/core'; + +const normalized = (value: string) => value.replace(/[^A-Za-z0-9]/g, '').toLowerCase(); + +/** Match a catalog resource to a graph node without a closed resource enum. */ +export const semanticResourceFor = ( + catalog: SemanticConceptCatalog | null | undefined, + resourceType: string, +) => { + const exact = catalog?.resources.find((resource) => resource.resourceType === resourceType); + if (exact) return exact; + const target = normalized(resourceType); + return catalog?.resources.find((resource) => { + const candidate = normalized(resource.resourceType); + return candidate === target || candidate.endsWith(target) || target.endsWith(candidate) || candidate.startsWith(target) || target.startsWith(candidate); + }); +}; + +export const semanticConceptsFor = ( + catalog: SemanticConceptCatalog | null | undefined, + resourceType: string, +): ReadonlyArray => { + const concepts = semanticResourceFor(catalog, resourceType)?.families + .flatMap((family) => family.concepts) ?? []; + const seen = new Set(); + return concepts.filter((concept) => { + if (seen.has(concept.id)) return false; + seen.add(concept.id); + return true; + }); +}; + +export const familyLabel = (id: string, label?: string) => + label?.trim() || id.trim() || 'Other concepts'; + +/** Recipe declaration names are stable implementation identifiers. Present + * them as plain language and drop generic lifecycle prefixes in the UI. */ +export const recipeFamilyLabel = (name: string) => { + const meaningful = name + .trim() + .replace(/^(?:(?:legacy|default|generated|auto)[_-]+)+/i, '') + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .trim(); + if (!meaningful) return 'Recipe columns'; + return meaningful.replace(/\b\w/g, (letter) => letter.toUpperCase()); +}; + +const compactPath = (value: unknown) => + typeof value === 'string' + ? value.trim().replace(/^root\./i, '').replace(/^FHIR\./i, '') + : ''; + +/** + * Return the shortest source description that distinguishes concepts which + * intentionally share a researcher-facing label. Loom concept identities are + * selector-based, so this never attempts to infer identity from the label. + */ +export const semanticConceptDisambiguator = (concept: SemanticConcept) => { + const sourcePath = compactPath( + concept.selector?.sourcePath || concept.source?.path || concept.source?.canonical, + ); + const valuePath = compactPath( + concept.selector?.valuePath || concept.source?.valuePath, + ); + const keySelector = compactPath( + concept.selector?.keySelector || concept.source?.keySelector, + ); + const parts = [sourcePath, keySelector, valuePath].filter( + (part, index, values) => part && values.indexOf(part) === index, + ); + return parts.join(' → '); +}; + +/** Convert a concept to the existing field shape used by table generation. */ +export const semanticFieldHint = (concept: SemanticConcept): FhirFieldHint => ({ + fieldRef: concept.id, + label: concept.label, + path: concept.selector?.valuePath || concept.column.name, + selector: concept.selector, + conceptId: concept.id, + ruleId: concept.ruleId, + columnName: concept.column.name, +}); + +export const semanticFieldsFor = ( + catalog: SemanticConceptCatalog | null | undefined, + resourceType: string, +): ReadonlyArray => semanticConceptsFor(catalog, resourceType).map(semanticFieldHint); + +const pathVariantsFor = (field: FhirFieldHint, resourceType: string) => { + const sourcePath = field.selector?.sourcePath?.trim(); + const valuePath = field.selector?.valuePath?.trim(); + const fullPath = sourcePath && valuePath + ? `${sourcePath.replace(/\.$/, '')}.${valuePath.replace(/^\./, '')}` + : sourcePath || valuePath || field.path || ''; + const withoutResource = fullPath.replace(new RegExp(`^${resourceType}[./]`, 'i'), ''); + return [fullPath, withoutResource, valuePath, field.columnName].filter(Boolean).map((value) => + String(value).replace(/^root\./i, '').replace(/\[\d*\]/g, '[]').toLowerCase(), + ); +}; + +/** Restore a semantic field from a legacy recipe expression when identity metadata is absent. */ +export const semanticFieldRefForPath = ( + catalog: SemanticConceptCatalog | null | undefined, + resourceType: string, + path: string, +) => { + const normalizedPath = path.replace(/^root\./i, '').replace(/\[\d*\]/g, '[]').toLowerCase(); + return semanticFieldsFor(catalog, resourceType).find((field) => + pathVariantsFor(field, resourceType).includes(normalizedPath), + )?.fieldRef; +}; + +export const conceptSelectionsFor = ( + catalog: SemanticConceptCatalog | null | undefined, + resourceType: string, + selected: ReadonlyArray, +) => semanticConceptsFor(catalog, resourceType) + .filter((concept) => selected.includes(concept.id)) + .map((concept) => ({ + conceptId: concept.id, + ruleId: concept.ruleId, + columnName: concept.column.name, + label: concept.label, + })); + +export const fieldSelectionFor = ( + fields: ReadonlyArray, + selected: ReadonlyArray, +) => fields.filter((field) => selected.includes(field.fieldRef)); + +export const isPartialSemanticCatalog = (catalog: SemanticConceptCatalog | null | undefined) => + catalog?.completeness?.state === 'partial' || + catalog?.diagnostics.some((diagnostic) => diagnostic.code.toLowerCase().includes('partial') || diagnostic.code.toLowerCase().includes('limit')) === true; + +/** Distinguish a successful empty Loom catalog from a failed request. */ +export const semanticCatalogAvailability = ( + catalog: SemanticConceptCatalog | null | undefined, +): 'ready' | 'empty' => + catalog?.resources.some((resource) => + resource.families.some((family) => family.concepts.length > 0), + ) + ? 'ready' + : 'empty'; diff --git a/packages/frontend/src/features/ExplorerBuilder/guided/semanticConcepts.unit.test.ts b/packages/frontend/src/features/ExplorerBuilder/guided/semanticConcepts.unit.test.ts new file mode 100644 index 00000000..049ae154 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/guided/semanticConcepts.unit.test.ts @@ -0,0 +1,93 @@ +import type { SemanticConceptCatalog } from '@gen3/core'; +import { conceptSelectionsFor, familyLabel, isPartialSemanticCatalog, semanticCatalogAvailability, semanticConceptDisambiguator, semanticConceptsFor, semanticFieldHint, semanticFieldRefForPath } from './semanticConcepts'; + +const fixture: SemanticConceptCatalog = { + schemaVersion: 2, + completeness: { state: 'partial' }, + resources: [{ + resourceType: 'ObservationLike', + families: [{ + id: 'future-clinical-domain-v9', + label: 'Clinical measurements', + concepts: [ + { id: 'observation.future-score', label: 'Future clinical score', family: 'future-clinical-domain-v9', ruleId: 'future.source.rule.v8', column: { name: 'future_clinical_score', logicalType: 'futureDecimal128' }, examples: { suppressed: true } }, + { id: 'observation.anatomical-sites', label: 'Anatomical sites', family: 'future-clinical-domain-v9', ruleId: 'observation.component.array.v2', selector: { sourcePath: 'Observation.component', valuePath: 'valueCodeableConcept.coding.display' }, column: { name: 'anatomical_sites', repeated: true }, repetition: { shape: 'array', rowExpansion: 'none' } }, + ], + }], + }], + diagnostics: [{ severity: 'warning', code: 'DISCOVERY_PARTIAL', message: 'partial' }], +} as SemanticConceptCatalog; + +describe('semantic concept picker helpers', () => { + it('distinguishes a successful empty catalog from one with concepts', () => { + expect(semanticCatalogAvailability({ + schemaVersion: 2, + resources: [{ resourceType: 'DocumentReference', families: [] }], + diagnostics: [], + })).toBe('empty'); + expect(semanticCatalogAvailability(fixture)).toBe('ready'); + }); + + it('groups unknown resource/rule families without a closed mapping', () => { + const concepts = semanticConceptsFor(fixture, 'Observation'); + expect(concepts.map((concept) => concept.ruleId)).toEqual([ + 'future.source.rule.v8', + 'observation.component.array.v2', + ]); + expect(familyLabel('future-clinical-domain-v9', 'Clinical measurements')).toBe('Clinical measurements'); + }); + + it('deduplicates repeated catalog entries by stable concept identity', () => { + const concept = fixture.resources[0].families[0].concepts[0]; + const repeated: SemanticConceptCatalog = { + ...fixture, + resources: [{ + ...fixture.resources[0], + families: [ + fixture.resources[0].families[0], + { id: 'duplicate-family', concepts: [concept] }, + ], + }], + }; + expect(semanticConceptsFor(repeated, 'ObservationLike').map(({ id }) => id)).toEqual([ + 'observation.future-score', + 'observation.anatomical-sites', + ]); + expect(conceptSelectionsFor(repeated, 'ObservationLike', [concept.id])).toHaveLength(1); + }); + + it('preserves suppressed examples and repeated array metadata', () => { + const concepts = semanticConceptsFor(fixture, 'ObservationLike'); + expect(concepts[0].examples?.suppressed).toBe(true); + expect(concepts[1].repetition?.rowExpansion).toBe('none'); + expect(semanticFieldHint(concepts[1]).columnName).toBe('anatomical_sites'); + expect(isPartialSemanticCatalog(fixture)).toBe(true); + }); + + it('describes the selector path that differentiates duplicate labels', () => { + expect(semanticConceptDisambiguator({ + id: 'category-code', + label: 'Category Value', + ruleId: 'CODEABLE_CONCEPT_VALUE', + selector: { + sourcePath: 'Observation.category', + keySelector: 'coding[].system', + valuePath: 'coding[].code', + }, + column: { name: 'category_code' }, + })).toBe('Observation.category → coding[].system → coding[].code'); + }); + + it('emits the v2 authoring identity payload without regenerating column names', () => { + expect(conceptSelectionsFor(fixture, 'ObservationLike', ['observation.anatomical-sites'])).toEqual([{ + conceptId: 'observation.anatomical-sites', + ruleId: 'observation.component.array.v2', + columnName: 'anatomical_sites', + label: 'Anatomical sites', + }]); + }); + + it('restores a concept from its selector path without using the display label', () => { + expect(semanticFieldRefForPath(fixture, 'ObservationLike', 'valueCodeableConcept.coding.display')).toBe('observation.anatomical-sites'); + }); +}); diff --git a/packages/frontend/src/features/ExplorerBuilder/index.ts b/packages/frontend/src/features/ExplorerBuilder/index.ts new file mode 100644 index 00000000..b6efe756 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/index.ts @@ -0,0 +1,2 @@ +export * from './session'; +export * from './ExplorerBuilderPage'; diff --git a/packages/frontend/src/features/ExplorerBuilder/recipe/RecipeVisualEditor.tsx b/packages/frontend/src/features/ExplorerBuilder/recipe/RecipeVisualEditor.tsx new file mode 100644 index 00000000..f57f3d6a --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/recipe/RecipeVisualEditor.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { isJSONObject, type JSONObject, type RecipeAuthoringDocument } from '@gen3/core'; + +type OutputRecord = JSONObject; + +const outputsOf = (document: RecipeAuthoringDocument): OutputRecord[] => + Array.isArray(document.outputs) + ? document.outputs.filter(isJSONObject) as OutputRecord[] + : []; + +export const RecipeVisualEditor = ({ + document, + onChange, + disabled = false, +}: { + readonly document: RecipeAuthoringDocument; + readonly onChange: (document: RecipeAuthoringDocument) => void; + readonly disabled?: boolean; +}) => { + const outputs = outputsOf(document); + const update = (next: OutputRecord[]) => onChange({ ...document, outputs: next }); + return ( +
+
+

Outputs

+ +
+ {outputs.length === 0 &&

No outputs defined.

} +
    + {outputs.map((output, index) => ( +
  1. +
    + { + const next = [...outputs]; + next[index] = { ...output, name: event.currentTarget.value }; + update(next); + }} + /> + { + const next = [...outputs]; + next[index] = { ...output, rootResourceType: event.currentTarget.value }; + update(next); + }} + /> + { + const next = [...outputs]; + next[index] = { ...output, rowGrain: event.currentTarget.value }; + update(next); + }} + /> + + + +
    +
  2. + ))} +
+
+ ); +}; diff --git a/packages/frontend/src/features/ExplorerBuilder/sample/ExplorerSamplePreview.tsx b/packages/frontend/src/features/ExplorerBuilder/sample/ExplorerSamplePreview.tsx new file mode 100644 index 00000000..15064c77 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/sample/ExplorerSamplePreview.tsx @@ -0,0 +1,309 @@ +import React from 'react'; +import type { RecipeDraftPreview } from '@gen3/core'; + +export type ExplorerSamplePreviewStatus = + | 'idle' + | 'loading' + | 'ready' + | 'error'; + +/** + * Presentation-only overrides for the columns in a Loom preview. + * `name` remains the source field name; `label` is what a steward sees. + */ +export interface ExplorerSampleColumnConfig { + /** Loom field name. `field` is accepted for direct Explorer table config use. */ + readonly name?: string; + readonly field?: string; + readonly label?: string; + readonly visible?: boolean; + readonly order?: number; +} + +export interface ExplorerSamplePreviewProps { + readonly preview?: RecipeDraftPreview; + readonly output?: string; + readonly onRetry?: () => void; + /** Optional lifecycle state from the preview session. Defaults from `preview`. */ + readonly status?: ExplorerSamplePreviewStatus; + /** Error text to announce when `status` is `error`. */ + readonly error?: string; + /** Keep showing the last preview while a new sample is being requested. */ + readonly isStale?: boolean; + /** Steward-defined labels, visibility, and order for preview columns. */ + readonly columnConfig?: ReadonlyArray; + /** Alias kept for callers that describe the input as configured columns. */ + readonly configuredColumns?: ReadonlyArray; + /** Human-readable directed traversal, e.g. `Patient → Condition → Specimen`. */ + readonly selectedPathSummary?: string | ReadonlyArray; + /** Render column labels as inputs directly above the live sample rows. */ + readonly editableHeaders?: boolean; + readonly onColumnLabelChange?: (sourceName: string, label: string) => void; + /** Move a visible column before the current target column. */ + readonly onColumnReorder?: (sourceName: string, targetName: string) => void; + readonly onColumnHide?: (sourceName: string) => void; + /** Builder embeds the table in its own workspace and does not need duplicate preview chrome. */ + readonly minimal?: boolean; +} + +const valueForCell = (value: unknown): string => { + if (value === null || value === undefined || value === '') return '—'; + if (typeof value === 'object') { + try { + return JSON.stringify(value) ?? '—'; + } catch { + return String(value); + } + } + return String(value); +}; + +const pathFor = ( + summary: ExplorerSamplePreviewProps['selectedPathSummary'], +): string | undefined => { + if (Array.isArray(summary)) return summary.filter(Boolean).join(' → ') || undefined; + return typeof summary === 'string' ? summary.trim() || undefined : undefined; +}; + +const configuredColumnMap = ( + configs: ReadonlyArray, +): ReadonlyMap => + new Map( + configs + .map((config) => [config.name ?? config.field, config] as const) + .filter((entry): entry is readonly [string, ExplorerSampleColumnConfig] => Boolean(entry[0])), + ); + +const columnsForPreview = ( + preview: RecipeDraftPreview, + configs: ReadonlyArray, +): ReadonlyArray => { + const configByName = configuredColumnMap(configs); + return preview.columns + .map((column, previewIndex) => { + const config = configByName.get(column.name); + return { + sourceName: column.name, + name: column.name, + label: config?.label || column.name, + visible: config?.visible !== false, + order: config?.order ?? (config ? configs.indexOf(config) : configs.length + previewIndex), + }; + }) + .filter((column) => column.visible !== false) + .sort((left, right) => left.order - right.order); +}; + +const statusTextFor = ( + status: ExplorerSamplePreviewStatus, + hasPreview: boolean, + isStale: boolean, +): string => { + if (status === 'loading' && hasPreview) return 'Refreshing sample preview…'; + if (status === 'loading') return 'Loading sample preview…'; + if (status === 'error') return isStale ? 'Showing the last successful preview.' : 'Sample preview could not be loaded.'; + if (isStale) return 'This sample is from an earlier traversal.'; + if (status === 'ready') return 'Sample preview is ready.'; + return 'Sample preview has not been run yet.'; +}; + +export const ExplorerSamplePreview = ({ + preview, + output, + onRetry, + status: requestedStatus, + error, + isStale = false, + columnConfig, + configuredColumns, + selectedPathSummary, + editableHeaders = false, + onColumnLabelChange, + onColumnReorder, + onColumnHide, + minimal = false, +}: ExplorerSamplePreviewProps) => { + const status = requestedStatus ?? (preview ? 'ready' : 'idle'); + const configs = columnConfig ?? configuredColumns ?? []; + const columns = preview ? columnsForPreview(preview, configs) : []; + const path = pathFor(selectedPathSummary); + const outputName = output || preview?.output || 'selected output'; + const rowCount = preview?.rowCount ?? 0; + const rows = preview?.rows ?? []; + const hasRows = Boolean(rows.length); + const showTable = Boolean(preview && columns.length > 0); + const statusText = statusTextFor(status, Boolean(preview), isStale); + const errorText = error || 'Try running the sample again.'; + const [draggedColumn, setDraggedColumn] = React.useState(); + const [dropTargetColumn, setDropTargetColumn] = React.useState(); + + return ( +
+ {!minimal &&
+
+
+
+

Sample preview

+ · {outputName} +
+

+ A read-only in-memory sample. Downloads are disabled. +

+ {path && ( +
+ Traversal + + {path} + +
+ )} +
+
+ {preview && ( + + {rowCount.toLocaleString()} {rowCount === 1 ? 'row' : 'rows'} + + )} + {onRetry && ( + + )} +
+
+

+ {statusText} +

+ {status === 'error' && ( +

+ {errorText} +

+ )} +
} + {minimal &&

{statusText}

} + + {status === 'loading' && !preview && ( +
+
+ )} + + {status !== 'loading' && status === 'idle' && !preview && ( +
+ Run a preview to inspect the first rows of {outputName}. +
+ )} + + {status !== 'loading' && status === 'error' && !preview && ( +
+ {errorText} + {onRetry && Use “Retry sample” to try again.} +
+ )} + + {preview && columns.length === 0 && ( +
+ No visible columns are configured for this sample yet. +
+ )} + + {showTable && ( + <> +

+ {hasRows ? `Showing ${rows.length.toLocaleString()} sample rows.` : 'The sample returned no rows.'} +

+
+ + + + + {columns.map((column) => ( + + ))} + + + + {!hasRows && ( + + + + )} + {rows.map((row, rowIndex) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
Sample rows for {outputName}
{ + setDraggedColumn(undefined); + setDropTargetColumn(undefined); + }} + onDragOver={(event) => { + if (draggedColumn && draggedColumn !== column.sourceName) { + event.preventDefault(); + setDropTargetColumn(column.sourceName); + } + }} + onDragStart={(event) => { + setDraggedColumn(column.sourceName); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', column.sourceName); + }} + onDrop={(event) => { + event.preventDefault(); + const sourceName = event.dataTransfer.getData('text/plain') || draggedColumn; + if (sourceName && sourceName !== column.sourceName) onColumnReorder?.(sourceName, column.sourceName); + setDraggedColumn(undefined); + setDropTargetColumn(undefined); + }} + scope="col" + title={editableHeaders && onColumnReorder ? 'Drag to reorder. The blue line marks where this column will be inserted.' : undefined} + > + {editableHeaders ?
+ onColumnLabelChange?.(column.sourceName, event.currentTarget.value)} /> +
+ {column.sourceName} + +
+
: column.label} +
+ No rows returned for this traversal. +
+
+ {valueForCell(row[column.sourceName])} +
+
+
+

+ Swipe horizontally to see all columns. +

+ + )} +
+ ); +}; diff --git a/packages/frontend/src/features/ExplorerBuilder/sample/ExplorerSamplePreview.unit.test.tsx b/packages/frontend/src/features/ExplorerBuilder/sample/ExplorerSamplePreview.unit.test.tsx new file mode 100644 index 00000000..07d5d5fa --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/sample/ExplorerSamplePreview.unit.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { RecipeDraftPreview } from '@gen3/core'; +import { ExplorerSamplePreview } from './ExplorerSamplePreview'; + +const preview: RecipeDraftPreview = { + output: 'Patient overview', + columns: [ + { name: 'id' } as RecipeDraftPreview['columns'][number], + { name: 'gender' } as RecipeDraftPreview['columns'][number], + { name: 'internal_note' } as RecipeDraftPreview['columns'][number], + ], + rows: [ + { id: 'p-001', gender: 'female', internal_note: 'hidden' }, + { id: 'p-002', gender: null, internal_note: 'hidden' }, + ], + rowCount: 2, + validation: { outputs: [], diagnostics: [] }, +}; + +describe('ExplorerSamplePreview', () => { + it('renders a configured, accessible table with ordered labels and hidden columns', () => { + render( + , + ); + + expect(screen.getByRole('heading', { name: /sample preview/i })).toBeInTheDocument(); + expect(screen.getByText('Patient → Condition → Specimen')).toBeInTheDocument(); + expect(screen.getByText('Administrative gender')).toBeInTheDocument(); + expect(screen.getByText('Patient ID')).toBeInTheDocument(); + expect(screen.queryByText('internal_note')).not.toBeInTheDocument(); + expect(screen.getByText('p-001')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + expect(screen.getByRole('table')).toHaveAccessibleName('Sample rows for Patient overview'); + expect(screen.getAllByRole('columnheader')).toHaveLength(2); + expect(screen.getAllByRole('columnheader')[0]).toHaveAttribute('scope', 'col'); + expect(screen.getByText(/swipe horizontally/i)).toBeInTheDocument(); + }); + + it('exposes idle, loading, error, and empty states without requiring preview data', () => { + const retry = jest.fn(); + const { rerender } = render(); + expect(screen.getByText(/run a preview to inspect/i)).toBeInTheDocument(); + + rerender(); + expect(screen.getByText(/preparing a sample/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /refreshing/i })).toBeDisabled(); + + rerender(); + expect(screen.getByRole('alert')).toHaveTextContent('Gateway timed out.'); + fireEvent.click(screen.getByRole('button', { name: /retry sample/i })); + expect(retry).toHaveBeenCalledTimes(1); + + rerender(); + expect(screen.getByText(/no rows returned/i)).toBeInTheDocument(); + }); + + it('keeps the last table visible while announcing a stale refresh or error', () => { + render( + , + ); + expect(screen.getByText(/showing the last successful preview/i)).toBeInTheDocument(); + expect(screen.getByText(/the new traversal failed/i)).toBeInTheDocument(); + expect(screen.getByText('p-001')).toBeInTheDocument(); + }); +}); + diff --git a/packages/frontend/src/features/ExplorerBuilder/session.tsx b/packages/frontend/src/features/ExplorerBuilder/session.tsx new file mode 100644 index 00000000..0ec11026 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/session.tsx @@ -0,0 +1,272 @@ +import React, { + createContext, + type Dispatch, + type PropsWithChildren, + useContext, + useMemo, + useReducer, +} from 'react'; +import type { + BuilderDiagnostic, + BuilderProject, + ExplorerAuthoringDocument, + RecipeAuthoringDocument, + RecipeDraftPreview, +} from '@gen3/core'; + +export interface PreviewState { + readonly key: string; + readonly status: 'idle' | 'loading' | 'ready' | 'error'; + readonly data?: RecipeDraftPreview; + readonly diagnostics?: ReadonlyArray; +} + +export const PREVIEW_DEBOUNCE_MS = 750; + +export const schedulePreview = ( + callback: () => void, + delay = PREVIEW_DEBOUNCE_MS, +): (() => void) => { + const timer = setTimeout(callback, delay); + return () => clearTimeout(timer); +}; + +export interface DraftConflictState { + readonly scope: 'recipe' | 'explorer'; + readonly currentVersion?: number; + readonly currentDigest?: string; + readonly updatedAt?: string; + readonly diagnostics: ReadonlyArray; +} + +export interface ExplorerBuilderSession { + readonly project: BuilderProject; + readonly recipeDraft: RecipeAuthoringDocument; + readonly explorerDraft: ExplorerAuthoringDocument; + readonly selectedRecipeRevisionId?: string; + readonly selectedOutput?: string; + readonly recipeDraftVersion: number; + readonly explorerDraftVersion: number; + readonly recipeDirty: boolean; + readonly explorerDirty: boolean; + readonly recipeDiagnostics: ReadonlyArray; + readonly explorerDiagnostics: ReadonlyArray; + readonly previewByOutput: Readonly>; + readonly conflict?: DraftConflictState; +} + +export type ExplorerBuilderAction = + | { + readonly type: 'loadRecipe'; + readonly draft: RecipeAuthoringDocument; + readonly version: number; + } + | { readonly type: 'editRecipe'; readonly draft: RecipeAuthoringDocument } + | { + readonly type: 'recipeSaved'; + readonly draft: RecipeAuthoringDocument; + readonly version: number; + } + | { + readonly type: 'loadExplorer'; + readonly draft: ExplorerAuthoringDocument; + readonly version: number; + } + | { readonly type: 'editExplorer'; readonly draft: ExplorerAuthoringDocument } + | { + readonly type: 'explorerSaved'; + readonly draft: ExplorerAuthoringDocument; + readonly version: number; + } + | { readonly type: 'selectRecipeRevision'; readonly revisionId?: string } + | { readonly type: 'selectOutput'; readonly output?: string } + | { + readonly type: 'recipeDiagnostics'; + readonly diagnostics: ReadonlyArray; + } + | { + readonly type: 'explorerDiagnostics'; + readonly diagnostics: ReadonlyArray; + } + | { + readonly type: 'previewLoading'; + readonly output: string; + readonly key: string; + } + | { + readonly type: 'previewReady'; + readonly output: string; + readonly key: string; + readonly data: RecipeDraftPreview; + } + | { + readonly type: 'previewError'; + readonly output: string; + readonly key: string; + readonly diagnostics: ReadonlyArray; + } + | { readonly type: 'draftConflict'; readonly conflict: DraftConflictState } + | { readonly type: 'clearDraftConflict' }; + +const stableValue = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, stableValue(child)]), + ); + } + return value; +}; + +export const canonicalizeAuthoringDocument = (document: unknown): string => + JSON.stringify(stableValue(document)); + +export const buildPreviewCacheKey = async ( + project: BuilderProject, + recipe: RecipeAuthoringDocument, + output: string, + limit: number, +): Promise => { + const value = `${project.organization}/${project.project}|${output}|${limit}|${canonicalizeAuthoringDocument(recipe)}`; + const cryptoApi = globalThis.crypto; + if (cryptoApi?.subtle && typeof TextEncoder !== 'undefined') { + const digest = await cryptoApi.subtle.digest( + 'SHA-256', + new TextEncoder().encode(value), + ); + return `sha256:${Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('')}`; + } + return `canonical:${value}`; +}; + +export const createExplorerBuilderSession = ( + project: BuilderProject, +): ExplorerBuilderSession => ({ + project, + recipeDraft: {}, + explorerDraft: {}, + recipeDraftVersion: 0, + explorerDraftVersion: 0, + recipeDirty: false, + explorerDirty: false, + recipeDiagnostics: [], + explorerDiagnostics: [], + previewByOutput: {}, + }); + +export const explorerBuilderReducer = ( + state: ExplorerBuilderSession, + action: ExplorerBuilderAction, +): ExplorerBuilderSession => { + switch (action.type) { + case 'loadRecipe': + case 'recipeSaved': + return { + ...state, + recipeDraft: action.draft, + recipeDraftVersion: action.version, + recipeDirty: false, + }; + case 'editRecipe': + return { ...state, recipeDraft: action.draft, recipeDirty: true }; + case 'loadExplorer': + case 'explorerSaved': + return { + ...state, + explorerDraft: action.draft, + explorerDraftVersion: action.version, + explorerDirty: false, + }; + case 'editExplorer': + return { ...state, explorerDraft: action.draft, explorerDirty: true }; + case 'selectRecipeRevision': + return { ...state, selectedRecipeRevisionId: action.revisionId }; + case 'selectOutput': + return { ...state, selectedOutput: action.output }; + case 'recipeDiagnostics': + return { ...state, recipeDiagnostics: action.diagnostics }; + case 'explorerDiagnostics': + return { ...state, explorerDiagnostics: action.diagnostics }; + case 'previewLoading': + return { + ...state, + previewByOutput: { + ...state.previewByOutput, + [action.output]: { key: action.key, status: 'loading' }, + }, + }; + case 'previewReady': { + if (state.previewByOutput[action.output]?.key !== action.key) + return state; + return { + ...state, + previewByOutput: { + ...state.previewByOutput, + [action.output]: { + key: action.key, + status: 'ready', + data: action.data, + }, + }, + }; + } + case 'previewError': { + if (state.previewByOutput[action.output]?.key !== action.key) + return state; + return { + ...state, + previewByOutput: { + ...state.previewByOutput, + [action.output]: { + key: action.key, + status: 'error', + diagnostics: action.diagnostics, + }, + }, + }; + } + case 'draftConflict': + return { ...state, conflict: action.conflict }; + case 'clearDraftConflict': + return { ...state, conflict: undefined }; + } +}; + +interface ExplorerBuilderContextValue { + readonly state: ExplorerBuilderSession; + readonly dispatch: Dispatch; +} + +const ExplorerBuilderContext = + createContext(null); + +export const ExplorerBuilderSessionProvider = ({ + project, + children, +}: PropsWithChildren<{ readonly project: BuilderProject }>) => { + const [state, dispatch] = useReducer( + explorerBuilderReducer, + project, + createExplorerBuilderSession, + ); + const value = useMemo(() => ({ state, dispatch }), [state]); + return ( + + {children} + + ); +}; + +export const useExplorerBuilderSession = (): ExplorerBuilderContextValue => { + const value = useContext(ExplorerBuilderContext); + if (!value) { + throw new Error( + 'useExplorerBuilderSession must be used within ExplorerBuilderSessionProvider', + ); + } + return value; +}; diff --git a/packages/frontend/src/features/ExplorerBuilder/session.unit.test.ts b/packages/frontend/src/features/ExplorerBuilder/session.unit.test.ts new file mode 100644 index 00000000..cebd1299 --- /dev/null +++ b/packages/frontend/src/features/ExplorerBuilder/session.unit.test.ts @@ -0,0 +1,80 @@ +import { + buildPreviewCacheKey, + canonicalizeAuthoringDocument, + createExplorerBuilderSession, + explorerBuilderReducer, + PREVIEW_DEBOUNCE_MS, + schedulePreview, +} from './session'; + +const project = { organization: 'org', project: 'project' }; + +describe('Explorer builder session', () => { + it('schedules opt-in live preview after the contract debounce', () => { + jest.useFakeTimers(); + const callback = jest.fn(); + const cancel = schedulePreview(callback); + jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS - 1); + expect(callback).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(1); + cancel(); + jest.useRealTimers(); + }); + it('canonicalizes object keys while retaining array order', () => { + expect( + canonicalizeAuthoringDocument({ z: 1, a: { y: 2, x: [3, 1] } }), + ).toBe('{"a":{"x":[3,1],"y":2},"z":1}'); + }); + + it('keys previews by a SHA-256 digest of project, canonical recipe, output, and limit', async () => { + const first = await buildPreviewCacheKey(project, { b: 2, a: 1 }, 'Files', 25); + const second = await buildPreviewCacheKey(project, { a: 1, b: 2 }, 'Files', 25); + const other = await buildPreviewCacheKey(project, { a: 1, b: 2 }, 'Files', 50); + expect(first).toBe(second); + expect(first).not.toBe(other); + expect(first.startsWith('sha256:') || first.startsWith('canonical:')).toBe( + true, + ); + }); + + it('discards superseded preview responses', () => { + const initial = createExplorerBuilderSession(project); + const first = explorerBuilderReducer(initial, { + type: 'previewLoading', + output: 'Files', + key: 'old', + }); + const second = explorerBuilderReducer(first, { + type: 'previewLoading', + output: 'Files', + key: 'new', + }); + const stale = explorerBuilderReducer(second, { + type: 'previewReady', + output: 'Files', + key: 'old', + data: { + output: 'Files', + columns: [], + rows: [], + rowCount: 0, + validation: { outputs: [], diagnostics: [] }, + }, + }); + expect(stale).toBe(second); + expect(stale.previewByOutput.Files.key).toBe('new'); + }); + + it('tracks recipe and Explorer dirty state independently', () => { + const recipeEdited = explorerBuilderReducer( + createExplorerBuilderSession(project), + { + type: 'editRecipe', + draft: { outputs: [] }, + }, + ); + expect(recipeEdited.recipeDirty).toBe(true); + expect(recipeEdited.explorerDirty).toBe(false); + }); +}); diff --git a/packages/frontend/src/features/Navigation/NavPageLayout.tsx b/packages/frontend/src/features/Navigation/NavPageLayout.tsx index 32d417b9..5ba76830 100644 --- a/packages/frontend/src/features/Navigation/NavPageLayout.tsx +++ b/packages/frontend/src/features/Navigation/NavPageLayout.tsx @@ -1,9 +1,73 @@ -import React, { useMemo, PropsWithChildren, useRef } from 'react'; +import React, { + CSSProperties, + PropsWithChildren, + useEffect, + useRef, +} from 'react'; import Head from 'next/head'; import Footer from './Footer/Footer'; import Header from './Header'; import { Sidebar, useResponsiveSidebar } from './Sidebar'; import { NavPageLayoutProps } from './types'; +import { PageLoadBoundary } from '../../components/MessageCards'; + +const DEFAULT_HEADER_HEIGHT = '4rem'; +const DEFAULT_FOOTER_HEIGHT = '0px'; + +type LayoutStyle = CSSProperties & { + '--gen3-header-height': string; + '--gen3-footer-height': string; +}; + +const useMeasuredLayoutParts = ( + rootRef: React.RefObject, + headerRef: React.RefObject, + footerRef: React.RefObject, +) => { + useEffect(() => { + const root = rootRef.current; + if (!root || typeof window === 'undefined') return undefined; + + const elements = [ + { ref: headerRef, variable: '--gen3-header-height' }, + { ref: footerRef, variable: '--gen3-footer-height' }, + ] as const; + + const updateSize = (element: HTMLDivElement, variable: string) => { + root.style.setProperty( + variable, + `${element.getBoundingClientRect().height}px`, + ); + }; + + elements.forEach(({ ref, variable }) => { + if (ref.current) updateSize(ref.current, variable); + }); + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(() => { + elements.forEach(({ ref, variable }) => { + if (ref.current) updateSize(ref.current, variable); + }); + }); + + elements.forEach(({ ref }) => { + if (ref.current) observer.observe(ref.current); + }); + + return () => observer.disconnect(); + } + + const handleResize = () => { + elements.forEach(({ ref, variable }) => { + if (ref.current) updateSize(ref.current, variable); + }); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [footerRef, headerRef, rootRef]); +}; const NavPageLayout = ({ headerProps, @@ -13,25 +77,44 @@ const NavPageLayout = ({ CustomHeaderComponent, CustomFooterComponent, children, + layoutMode = 'document', + pageProblems, }: PropsWithChildren) => { const leftNavDisabled = headerMetadata.title === 'CALYPR Landing Page'; + const rootRef = useRef(null); + const headerRef = useRef(null); const footerRef = useRef(null); const { finalState, toggleButton } = useResponsiveSidebar(leftNavDisabled); - const { className: mainClassName, ...resolvedMainProps } = mainProps ?? {}; + const { + className: mainClassName, + style: mainStyle, + ...resolvedMainProps + } = mainProps ?? {}; + const isViewportLayout = layoutMode === 'viewport'; - const mainPadding = useMemo(() => { - const paddingTop = 'pt-16'; // For 64px header height - let paddingBottom = 'pb-20'; // Fallback for ~80px footer height - if (footerRef.current) { - paddingBottom = `pb-[${footerRef.current.offsetHeight}px]`; // Dynamic footer height - } - const padding = `${paddingTop} ${paddingBottom}`; - return padding; - }, []); + useMeasuredLayoutParts(rootRef, headerRef, footerRef); + + const layoutStyle: LayoutStyle = { + '--gen3-header-height': DEFAULT_HEADER_HEIGHT, + '--gen3-footer-height': DEFAULT_FOOTER_HEIGHT, + display: 'flex', + flexDirection: 'column', + minHeight: '100dvh', + ...(isViewportLayout + ? { + height: '100dvh', + overflow: 'hidden', + } + : {}), + }; return ( -
+
{headerMetadata.title || 'App'} {headerMetadata.content && ( @@ -43,40 +126,62 @@ const NavPageLayout = ({ )} - {/* HEADER */} - {CustomHeaderComponent ? ( - - ) : ( -
- )} - - {/* BODY */} -
- {/* Sidebar */} +
+ {CustomHeaderComponent ? ( + + ) : ( +
+ )} +
+ +
{!leftNavDisabled && ( )} - {/* Main Content */}
- {children} + {children}
- {/* FOOTER */} -
+
{CustomFooterComponent ? ( ) : ( diff --git a/packages/frontend/src/features/Navigation/NavPageLayout.unit.test.tsx b/packages/frontend/src/features/Navigation/NavPageLayout.unit.test.tsx new file mode 100644 index 00000000..b13a7a24 --- /dev/null +++ b/packages/frontend/src/features/Navigation/NavPageLayout.unit.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import NavPageLayout from './NavPageLayout'; + +jest.mock('next/head', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('./Header', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('./Footer/Footer', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('./Sidebar', () => ({ + Sidebar: () =>
- -
- {schemaError && {schemaError}} - {executionError && Error: {executionError}} + {projectRequired && ( +
+ +
+ )} +
+ {schemaError && ( + + {schemaError} + + )} + {executionError && ( + + Error: {executionError} + + )} !t.startsWith('__')) : []} - onChange={(val) => { - if (val && schema) { - const type = schema.getType(val); - if (type) setDocHistory([{ name: val, type: getBaseType(type) }]); - } - }} - leftSection={} - /> +
+