diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 48e294064..daa9c8e19 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -388,6 +388,51 @@ app.use("/verify-email", async function (req, res, next) { } }); +// Multiple fragments GeoJSON (must be before /fragments/:hash/geojson) +app.get("/fragments/geojson", async function (req: SSNRequest, res) { + if (!req.user?.id) { + return res.status(401).json({ error: "Authentication required" }); + } + const hashesParam = req.query.hashes; + const hashes = + typeof hashesParam === "string" + ? hashesParam.split(",").map((h) => h.trim()).filter(Boolean) + : Array.isArray(hashesParam) + ? (hashesParam as string[]).map((h) => String(h).trim()).filter(Boolean) + : []; + if (hashes.length === 0) { + return res.status(400).json({ error: "At least one fragment hash is required (hashes=hash1,hash2,...)" }); + } + const client = await loadersPool.connect(); + try { + const { rows } = await client.query( + `SELECT hash, ST_AsGeoJSON(geometry)::json as geometry FROM fragments WHERE hash = ANY($1::text[])`, + [hashes], + ); + const features = rows.map( + (row: { hash: string; geometry: unknown }) => ({ + type: "Feature" as const, + properties: { hash: row.hash }, + geometry: row.geometry, + }), + ); + const geojson = { + type: "FeatureCollection" as const, + features, + }; + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "Content-Disposition", + `attachment; filename=fragments.geojson.json`, + ); + res.send(JSON.stringify(geojson)); + } catch (e: any) { + res.status(500).json({ error: e.message }); + } finally { + client.release(); + } +}); + app.get("/fragments/:hash/geojson", async function (req: SSNRequest, res) { if (!req.user?.id) { return res.status(401).json({ error: "Authentication required" }); diff --git a/packages/api/src/sketches.ts b/packages/api/src/sketches.ts index ca7917607..4e38ac497 100644 --- a/packages/api/src/sketches.ts +++ b/packages/api/src/sketches.ts @@ -678,6 +678,9 @@ export async function updateSketchFragments( pgClient: PoolClient, deletionScope?: string[], ): Promise { + if (fragments.length > 80) { + throw new Error("Too many fragments to update. Maximum is 80."); + } const fragmentInputs = fragments .map((f) => { const geomJson = JSON.stringify(f.geometry); diff --git a/packages/client/public/slashCommands/raster-proportion.png b/packages/client/public/slashCommands/raster-proportion.png new file mode 100644 index 000000000..c97ac2e55 Binary files /dev/null and b/packages/client/public/slashCommands/raster-proportion.png differ diff --git a/packages/client/src/admin/users/ProfilePhoto.tsx b/packages/client/src/admin/users/ProfilePhoto.tsx index 6a9b12d18..a6a5e366a 100644 --- a/packages/client/src/admin/users/ProfilePhoto.tsx +++ b/packages/client/src/admin/users/ProfilePhoto.tsx @@ -8,6 +8,7 @@ import clsx from "clsx"; // const supportsSrcSet = "srcset" in document.createElement("img"); export default function ProfilePhoto({ + className, fullname, email, canonicalEmail, @@ -16,6 +17,7 @@ export default function ProfilePhoto({ border, square, }: { + className?: string; fullname?: string | Maybe; email?: string | Maybe; canonicalEmail: string; @@ -40,6 +42,7 @@ export default function ProfilePhoto({ } className={clsx( `w-full h-full inline-block`, + className, square ? "rounded-sm" : "rounded-full", border ? "border-2 shadow bg-white border-white" : "" )} diff --git a/packages/client/src/index.css b/packages/client/src/index.css index 61539b20f..5fb765fb4 100644 --- a/packages/client/src/index.css +++ b/packages/client/src/index.css @@ -8853,6 +8853,10 @@ select{ overflow-wrap:break-word } +.break-all{ + word-break:break-all +} + .rounded{ border-radius:0.25rem } @@ -9376,6 +9380,10 @@ select{ border-color:rgb(253 230 138 / var(--tw-border-opacity, 1)) } +.border-white\/20{ + border-color:rgb(255 255 255 / 0.2) +} + .border-b-black{ --tw-border-opacity:1; border-bottom-color:rgb(0 0 0 / var(--tw-border-opacity, 1)) @@ -11795,6 +11803,14 @@ select{ color:rgb(255 255 255 / 0.8) } +.text-white\/70{ + color:rgb(255 255 255 / 0.7) +} + +.text-white\/50{ + color:rgb(255 255 255 / 0.5) +} + .text-opacity-50{ --tw-text-opacity:0.5 } diff --git a/packages/client/src/reports/ReportMetricsProgressDetails.tsx b/packages/client/src/reports/ReportMetricsProgressDetails.tsx index afb0ab9f1..eaa0992c5 100644 --- a/packages/client/src/reports/ReportMetricsProgressDetails.tsx +++ b/packages/client/src/reports/ReportMetricsProgressDetails.tsx @@ -1,22 +1,28 @@ import { Trans, useTranslation } from "react-i18next"; import { useCallback, useContext, useMemo } from "react"; import { + CompatibleSpatialMetricDetailsFragment, + DataSourceTypes, DraftReportDependenciesDocument, Geography, ReportDependenciesDocument, SpatialMetricState, useRecalculateSpatialMetricsMutation, + useProjectReportingLayersQuery, } from "../generated/graphql"; import { subjectIsFragment } from "overlay-engine"; import ReportTaskLineItem from "./components/ReportTaskLineItem"; import * as Tooltip from "@radix-ui/react-tooltip"; -import { DownloadIcon } from "@radix-ui/react-icons"; import { ReportCardConfiguration } from "./cards/cards"; import { DraftReportContext } from "./DraftReportContext"; import { useCardDependenciesContext } from "./context/CardDependenciesContext"; import { useBaseReportContext } from "./context/BaseReportContext"; import { useGlobalErrorHandler } from "../components/GlobalErrorHandler"; import { useAuth0 } from "@auth0/auth0-react"; +import getSlug from "../getSlug"; +import ProfilePhoto from "../admin/users/ProfilePhoto"; +import type { AuthorProfileFragment } from "../generated/graphql"; +import { nameForProfile } from "../projects/Forums/TopicListItem"; export default function ReportMetricsProgressDetails({ config, @@ -84,6 +90,94 @@ export default function ReportMetricsProgressDetails({ context.sources, ]); + const groupedGeographyMetrics = useMemo( + () => + groupMetricsBySourceAndOperation( + state.geographyMetrics, + state.relatedOverlaySources, + t + ), + [state.geographyMetrics, state.relatedOverlaySources, t] + ); + + const groupedFragmentMetrics = useMemo( + () => + groupMetricsBySourceAndOperation( + state.fragmentMetrics, + state.relatedOverlaySources, + t + ), + [state.fragmentMetrics, state.relatedOverlaySources, t] + ); + + const uniqueFragmentHashes = useMemo( + () => + new Set( + state.fragmentMetrics.map((m) => (m.subject as { hash: string }).hash) + ).size, + [state.fragmentMetrics] + ); + + const geographyDurationSummary = useMemo(() => { + const settled = + state.geographyMetrics.length > 0 && + state.geographyMetrics.every( + (m) => + m.state === SpatialMetricState.Complete || + m.state === SpatialMetricState.Error + ); + if (!settled) return null; + const total = state.geographyMetrics.reduce( + (sum, m) => sum + (m.durationSeconds ?? 0), + 0 + ); + return total > 0 ? { totalSeconds: total } : null; + }, [state.geographyMetrics]); + + const fragmentDurationSummary = useMemo(() => { + const settled = + state.fragmentMetrics.length > 0 && + state.fragmentMetrics.every( + (m) => + m.state === SpatialMetricState.Complete || + m.state === SpatialMetricState.Error + ); + if (!settled) return null; + const total = state.fragmentMetrics.reduce( + (sum, m) => sum + (m.durationSeconds ?? 0), + 0 + ); + return total > 0 ? { totalSeconds: total } : null; + }, [state.fragmentMetrics]); + + const { data: reportingLayersData } = useProjectReportingLayersQuery({ + variables: { slug: getSlug() }, + fetchPolicy: "cache-only", + }); + + const overlayAttributionByTocId = useMemo(() => { + const items = + reportingLayersData?.projectBySlug?.draftTableOfContentsItems ?? []; + const map = new Map< + number, + { + profile: AuthorProfileFragment | null; + createdAt: string | null; + sourceTypeLabel: string; + } + >(); + for (const item of items) { + const ds = item.dataLayer?.dataSource; + if (!ds || !item.id) continue; + map.set(item.id, { + profile: ds.authorProfile ?? null, + createdAt: ds.createdAt ?? null, + sourceTypeLabel: sourceTypeLabel(ds.type, t), + }); + } + return map; + }, [reportingLayersData?.projectBySlug?.draftTableOfContentsItems, t]); + return (
@@ -99,64 +193,103 @@ export default function ReportMetricsProgressDetails({ updated.

-
    +
      {state.relatedOverlaySources.map((layer) => { const isComplete = layer.sourceProcessingJob?.state === SpatialMetricState.Complete || Boolean(layer.output); + const tocId = layer.tableOfContentsItemId; + const attribution = + tocId != null + ? overlayAttributionByTocId.get(tocId) + : undefined; + const displayDate = + layer.output?.createdAt ?? attribution?.createdAt ?? null; + const layerTitle = + layer.tableOfContentsItem?.title || t("Untitled"); return ( - - handleRepairSource( - layer.sourceProcessingJob!.jobKey - ) - : undefined - } - repairLoading={recalculateState.loading} - /> +
    • + + {attribution?.profile && ( + + + + )} + + {layerTitle} + + {attribution && ( + + {attribution.sourceTypeLabel} + + )} + + + + handleRepairSource( + layer.sourceProcessingJob!.jobKey + ) + : undefined + } + repairLoading={recalculateState.loading} + /> + +
    • ); })}
    @@ -172,38 +305,67 @@ export default function ReportMetricsProgressDetails({ recalculated if a source layer is updated.

    -
      - {state.geographyMetrics.map((metric) => ( - - layer.output || - layer.sourceProcessingJob?.state === - SpatialMetricState.Complete - )} - /> + {geographyDurationSummary && ( +

      + {t("Total compute duration")}:{" "} + {geographyDurationSummary.totalSeconds.toFixed(1)}{" "} + {t("seconds")} +

      + )} +
      + {groupedGeographyMetrics.map((group) => ( +
      +
      +
      + + {group.overlayName} + + + {group.operationLabel} + +
      + {/* + {group.metrics.length} {t("metrics")} + */} +
      +
        + {group.metrics.map((metric) => ( + + layer.output || + layer.sourceProcessingJob?.state === + SpatialMetricState.Complete + )} + /> + ))} +
      +
      ))} -
    +
)} {state.fragmentMetrics.length > 0 && ( @@ -215,93 +377,104 @@ export default function ReportMetricsProgressDetails({ Polygons may be split in order to account for antimeridian crossings or overlap with other sketches in a collection. + {isAuthenticated && ( + <> + {" "} + + + )}

-
    - {state.fragmentMetrics.map((metric) => ( - - - {t("Polygon ")} - - {(metric.subject as { hash: string }).hash.substring( - 0, - 36 - )} - +

    + {uniqueFragmentHashes} {t("unique fragments")} {t("and")}{" "} + {groupedFragmentMetrics.length} {t("operation groups")} +

    + {fragmentDurationSummary && ( +

    + {t("Total compute duration")}:{" "} + {fragmentDurationSummary.totalSeconds.toFixed(1)}{" "} + {t("seconds")} +

    + )} +
    + {groupedFragmentMetrics.map((group) => ( +
    +
    +
    + + {group.overlayName} - {isAuthenticated && ( - - - { - e.stopPropagation(); - const hash = ( - metric.subject as { hash: string } - ).hash; - try { - const token = - await getAccessTokenSilently(); - const url = - // eslint-disable-next-line i18next/no-literal-string - process.env.REACT_APP_GRAPHQL_ENDPOINT!.replace( - "/graphql", - `/fragments/${hash}/geojson?token=${token}` - ); - window.open(url, "_blank"); - } catch (e) { - console.error( - "Failed to get access token", - e - ); - } - }} - > - + + {group.operationLabel} + +
    + {/* + {group.metrics.length} {t("metrics")} + */} +
    +
      + {group.metrics.map((metric) => ( + + {t("Polygon ")} + + {( + metric.subject as { hash: string } + ).hash.substring(0, 36)} - - - - {t("Download GeoJSON")} - - - - - )} - - } - state={metric.state} - progress={metric.progress || null} - startedAt={metric.startedAt} - progressPercent={metric.progress || null} - completedAt={metric.updatedAt} - durationSeconds={metric.durationSeconds} - errorMessage={metric.errorMessage} - estimatedCompletionTime={metric.eta} - isAdmin={isAdmin} - value={metric.value} - sourcesReady={state.relatedOverlaySources.every( - (layer) => - layer.output || - layer.sourceProcessingJob?.state === - SpatialMetricState.Complete - )} - /> + + } + state={metric.state} + progress={metric.progress || null} + startedAt={metric.startedAt} + progressPercent={metric.progress || null} + completedAt={metric.updatedAt} + durationSeconds={metric.durationSeconds} + errorMessage={metric.errorMessage} + estimatedCompletionTime={metric.eta} + isAdmin={isAdmin} + value={metric.value} + sourcesReady={state.relatedOverlaySources.every( + (layer) => + layer.output || + layer.sourceProcessingJob?.state === + SpatialMetricState.Complete + )} + /> + ))} +
    +
    ))} -
+ )} @@ -321,3 +494,174 @@ function nameForGeography( } return geographies.find((g) => g.id === subject.id)?.name; } + +function sourceTypeLabel( + type: DataSourceTypes, + t: (key: string) => string +): string { + const rasterTypes = [ + DataSourceTypes.Raster, + DataSourceTypes.RasterDem, + DataSourceTypes.SeasketchRaster, + DataSourceTypes.ArcgisRasterTiles, + DataSourceTypes.Image, + DataSourceTypes.ArcgisDynamicMapserver, + DataSourceTypes.ArcgisDynamicMapserverRasterSublayer, + ]; + if (rasterTypes.includes(type)) return t("Raster"); + return t("Vector"); +} + +function AuthorAvatarWithTooltip({ + profile, +}: { + profile: AuthorProfileFragment; +}) { + const { t } = useTranslation("sketching"); + const name = nameForProfile(profile); + return ( + + + + + + + {/* {name || t("Unknown")} */} + + + + +
+ {profile.fullname && ( +
+ {t("Uploaded by ")} + {profile.fullname} +
+ )} + {profile.email && ( +
{profile.email}
+ )} + {profile.affiliations && ( +
+ {profile.affiliations} +
+ )} +
+ +
+
+
+ ); +} + +function groupMetricsBySourceAndOperation( + metrics: CompatibleSpatialMetricDetailsFragment[], + overlaySources: Array<{ + tableOfContentsItem?: { title?: string | null } | null; + output?: { url?: string | null } | null; + }>, + t: (key: string) => string +) { + const sourceNameByUrl = new Map(); + for (const source of overlaySources) { + if (!source.output?.url) continue; + sourceNameByUrl.set( + source.output.url, + source.tableOfContentsItem?.title || t("Untitled overlay") + ); + } + + const groups = new Map< + string, + { + key: string; + overlayName: string; + operationLabel: string; + metrics: CompatibleSpatialMetricDetailsFragment[]; + } + >(); + + for (const metric of metrics) { + const operationLabel = metricTypeLabel(metric.type, t); + const overlayName = sourceLabelForMetric(metric, sourceNameByUrl, t); + const key = `${overlayName}::${operationLabel}`; + if (!groups.has(key)) { + groups.set(key, { + key, + overlayName, + operationLabel, + metrics: [], + }); + } + groups.get(key)!.metrics.push(metric); + } + + return Array.from(groups.values()).sort((a, b) => { + const overlayCmp = a.overlayName.localeCompare(b.overlayName); + if (overlayCmp !== 0) return overlayCmp; + return a.operationLabel.localeCompare(b.operationLabel); + }); +} + +function sourceLabelForMetric( + metric: CompatibleSpatialMetricDetailsFragment, + sourceNameByUrl: Map, + t: (key: string) => string +) { + if (metric.type === "total_area") { + return t("Sketch geometry"); + } + if (!metric.sourceUrl) { + return t("Unknown source"); + } + const direct = sourceNameByUrl.get(metric.sourceUrl); + if (direct) return direct; + + for (const [url, name] of sourceNameByUrl.entries()) { + if (url.includes(metric.sourceUrl) || metric.sourceUrl.includes(url)) { + return name; + } + } + + try { + const parsed = new URL(metric.sourceUrl); + const segments = parsed.pathname.split("/").filter(Boolean); + const lastSegment = segments[segments.length - 1]; + return decodeURIComponent(lastSegment || metric.sourceUrl); + } catch { + return metric.sourceUrl; + } +} + +function metricTypeLabel(type: string, t: (key: string) => string) { + switch (type) { + case "overlay_area": + return t("Overlay area"); + case "count": + return t("Count"); + case "presence": + return t("Presence"); + case "presence_table": + return t("Presence table"); + case "column_values": + return t("Column values"); + case "raster_stats": + return t("Raster statistics"); + case "distance_to_shore": + return t("Distance to shore"); + case "total_area": + return t("Total area"); + default: + return type; + } +} diff --git a/packages/client/src/reports/components/ReportTaskLineItem.tsx b/packages/client/src/reports/components/ReportTaskLineItem.tsx index 16fc937b9..034beaa93 100644 --- a/packages/client/src/reports/components/ReportTaskLineItem.tsx +++ b/packages/client/src/reports/components/ReportTaskLineItem.tsx @@ -13,6 +13,8 @@ import bytes from "bytes"; import { Trans, useTranslation } from "react-i18next"; interface ReportTaskLineItemProps { + /** When true, renders only ETA + status icon (no title, no li). Use for inline rows that supply their own title. */ + onlyStatus?: boolean; title: React.ReactNode; state: SpatialMetricState; progress?: number | null; @@ -40,6 +42,7 @@ interface ReportTaskLineItemProps { } export default function ReportTaskLineItem({ + onlyStatus = false, title, state, progress, @@ -269,9 +272,8 @@ export default function ReportTaskLineItem({ ? queuedTooltip : null; - return ( -
  • - {title} + const statusBlock = ( + <> ) : ( -
    +
    ) : ( - + )} + + ); + + if (onlyStatus) { + return statusBlock; + } + + return ( +
  • + {title} + {statusBlock}
  • ); } diff --git a/packages/client/src/reports/hooks/useNumberFormatters.ts b/packages/client/src/reports/hooks/useNumberFormatters.ts index 4b34c25dc..a945fc4e3 100644 --- a/packages/client/src/reports/hooks/useNumberFormatters.ts +++ b/packages/client/src/reports/hooks/useNumberFormatters.ts @@ -197,13 +197,15 @@ export function useNumberFormatters({ const percent = useCallback( (value: number) => { if (value > 1.05) { - throw new Error( - `Percent value is greater than 100%. Value: ${value * 100}%` - ); + // throw new Error( + // `Percent value is greater than 100%. Value: ${value * 100}%` + // ); console.error( Error(`Percent value is greater than 100%. Value: ${value * 100}%`) ); return "100%"; + } else if (value > 1) { + // keep inaccurate values } else if (value > 0.9999) { // Very small rounding issues are fine value = 1; diff --git a/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx b/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx index 4facca0bb..5d0e8c41a 100644 --- a/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx +++ b/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx @@ -31,7 +31,7 @@ import { ClassTableRowComponentSettings, classTableRowKey, getClassTableRows, -} from "./FeatureCountTable"; +} from "./ClassTableRows"; import { MetricDependency } from "overlay-engine"; import { OverlaySourceDetailsFragment } from "../../generated/graphql"; import { GeostatsLayer, isGeostatsLayer } from "@seasketch/geostats-types"; @@ -125,6 +125,8 @@ type ClassRowSettingsPopoverProps = { /** Show the "Show color swatches" toggle in the footer. */ showColorSwatches?: boolean; onShowColorSwatchesChange?: (value: boolean) => void; + /** Hide the "Group by" section (for raster sources that have no attribute columns). */ + hideGroupBy?: boolean; }; export const ClassRowSettingsPopover = ({ @@ -140,6 +142,7 @@ export const ClassRowSettingsPopover = ({ onShowZerosChange, showColorSwatches, onShowColorSwatchesChange, + hideGroupBy, }: ClassRowSettingsPopoverProps) => { const overlayOptions = useOverlayOptionsForLayerToggle(t); const { allSources: overlaySources } = useOverlaySources(); @@ -184,23 +187,30 @@ export const ClassRowSettingsPopover = ({ onUpdateAllDependencies((currentDeps) => { const newDeps = [...currentDeps]; - const params: Record = {}; + const baseParams: Record = {}; if (bufferDistanceKm !== undefined) { - params.bufferDistanceKm = bufferDistanceKm; + baseParams.bufferDistanceKm = bufferDistanceKm; } for (const layerValue of validLayers) { + const source = overlaySources.find( + (s) => s.stableId === layerValue.stableId + ); + const parameters = { ...baseParams }; + if (source?.containsOverlappingFeatures) { + parameters.sourceHasOverlappingFeatures = true; + } newDeps.push({ type: metricType, subjectType: "fragments", stableId: layerValue.stableId, - parameters: params, + parameters, }); newDeps.push({ type: metricType, subjectType: "geographies", stableId: layerValue.stableId, - parameters: params, + parameters, }); } @@ -454,6 +464,7 @@ export const ClassRowSettingsPopover = ({

    */}
    + {!hideGroupBy && (

    {t("Group by")} @@ -488,6 +499,7 @@ export const ClassRowSettingsPopover = ({ }} />

    + )} {metricType === "overlay_area" && group.source?.stableId && (
    diff --git a/packages/client/src/reports/widgets/ClassTableRows.ts b/packages/client/src/reports/widgets/ClassTableRows.ts new file mode 100644 index 000000000..934d39d21 --- /dev/null +++ b/packages/client/src/reports/widgets/ClassTableRows.ts @@ -0,0 +1,329 @@ +import { MetricDependency, Metric, subjectIsFragment, subjectIsGeography, combineMetricsForFragments } from "overlay-engine"; +import { AnyLayer } from "mapbox-gl"; +import { GeostatsLayer } from "@seasketch/geostats-types"; +import { + CompatibleSpatialMetricDetailsFragment, + OverlaySourceDetailsFragment, + SpatialMetricState, +} from "../../generated/graphql"; +import { + extractColorForLayers, + extractColorsForCategories, +} from "../utils/colors"; + +export type ClassTableRow = { + key: string; + label: string; + groupByKey: string; + sourceId: string; + color?: string; + /** For vector or single-color raster; use with a single swatch. */ + stableId?: string; + /** + * For rasters: color stops from the mapbox-gl-style raster-color expression + * (interpolate, step, match, etc.). Used to render a multi-color swatch. + */ + colors?: string[]; +}; + +/** + * True when the overlay source is a raster dataset (geostats has bands, not vector layers). + */ +function isRasterSource( + source: OverlaySourceDetailsFragment +): source is OverlaySourceDetailsFragment & { geostats: { bands: unknown[] } } { + const g = source?.geostats; + return ( + typeof g === "object" && + g !== null && + "bands" in g && + Array.isArray((g as { bands: unknown[] }).bands) && + (g as { bands: unknown[] }).bands.length >= 1 + ); +} + +/** + * True if the color string is considered transparent (keyword, rgba(,,,0), or hex with alpha 0). + */ +function isTransparentColor(c: string): boolean { + const s = c.trim().toLowerCase(); + if (s === "transparent") return true; + if (s.startsWith("rgba(") && s.endsWith(")")) { + const lastComma = s.lastIndexOf(","); + if (lastComma !== -1) { + const alpha = s.slice(lastComma + 1, s.length - 1).trim(); + if (alpha === "0" || alpha === "0.0" || /^0\.0+$/.test(alpha)) return true; + } + } + if (/^#[0-9a-f]{8}$/i.test(s) && s.slice(-2).toLowerCase() === "00") + return true; + if (/^#[0-9a-f]{4}$/i.test(s) && s.slice(-1).toLowerCase() === "0") + return true; + return false; +} + +/** + * Extracts ordered color values from a raster layer's raster-color paint + * expression, whether interpolate, step, or match. Returns undefined if none found. + * Transparent colors (keyword, rgba with alpha 0, hex with alpha 00) are excluded. + */ +function getRasterColorsFromStyle(layers: AnyLayer[]): string[] | undefined { + for (const layer of layers) { + if (layer.type !== "raster" || !layer.paint) continue; + const rasterColor = (layer.paint as Record)["raster-color"]; + if (!Array.isArray(rasterColor) || rasterColor.length < 3) continue; + const fn = rasterColor[0]; + if (typeof fn !== "string") continue; + + const colorStops: string[] = []; + + const pushIfOpaque = (c: string) => { + if (typeof c === "string" && !isTransparentColor(c)) colorStops.push(c); + }; + + if (/^interpolate(-hcl|-lab)?$/.test(fn)) { + // [ "interpolate", interpolation, input, v1, c1, v2, c2, ... ] + for (let i = 4; i < rasterColor.length; i += 2) { + pushIfOpaque(rasterColor[i] as string); + } + } else if (fn === "step") { + // [ "step", input, defaultColor, v1, c1, v2, c2, ... ] + for (let i = 2; i < rasterColor.length; i += 2) { + pushIfOpaque(rasterColor[i] as string); + } + } else if (fn === "match") { + // [ "match", input, c1, v1, c2, v2, ..., default ] — values at odd indices and last + for (let i = 3; i < rasterColor.length - 1; i += 2) { + pushIfOpaque(rasterColor[i] as string); + } + const defaultVal = rasterColor[rasterColor.length - 1]; + if (typeof defaultVal === "string") pushIfOpaque(defaultVal); + } + + if (colorStops.length > 0) return colorStops; + } + return undefined; +} + +export type ClassTableRowComponentSettings = { + /** + * A list of row keys to exclude from the table. The key must match the ClassTableRow.key value. + */ + excludedRowKeys?: string[]; + /** + * A map of row keys to custom labels. The key must match the ClassTableRow.key value. + */ + customRowLabels?: { [key: string]: string }; + /** + * A map of row keys to stable IDs. The key must match the ClassTableRow.key value. + */ + rowLinkedStableIds?: { [key: string]: string }; +}; + +export function classTableRowKey(stableId: string, groupByKey?: string) { + return `${stableId}-${groupByKey || "*"}`; +} + +/** + * Returns a list of ClassTableRows for the given dependencies and sources. In + * report widgets like FeatureCountTable and OverlappingAreasTable, the widget + * can populate these rows with metrics data for rendering as part of a table + * component. The purpose of this function is to let table widgets delegate + * responsibility for determining what rows to display, their colors, and their + * labels, based on depedencies and common configuration. Widget-specific + * functionality can then be implemented by callers. + * + * If sources aren't provided, likely because they haven't been loaded yet, the + * function will return placeholder rows. + * @param options + * @returns + */ +export function getClassTableRows(options: { + dependencies: MetricDependency[]; + sources: OverlaySourceDetailsFragment[]; + allFeaturesLabel: string; + /** + * A map of group by keys to custom labels. The key must match the + * ClassTableRow.key value. + */ + customLabels?: { [key: string]: string }; + /** + * A map of table of contents item IDs to stable IDs for showing map layer + * toggles. The key must match the ClassTableRow.sourceId value. + */ + stableIds?: { [key: string]: string }; + excludedRowKeys?: string[]; +}): ClassTableRow[] { + const rows = [] as ClassTableRow[]; + const fragmentDependencies = options.dependencies.filter( + (d) => d.subjectType === "fragments" && Boolean(d.stableId) + ); + const multiSource = + fragmentDependencies.length > 1 && options.sources.length > 1; + for (const dependency of fragmentDependencies) { + const source = options.sources.find( + (s) => s.stableId === dependency.stableId + ); + const layer = source?.geostats?.layers?.[0] as GeostatsLayer | undefined; + if (!source || !layer) { + if (dependency.parameters?.groupBy) { + [1, 2, 3].forEach((i) => { + rows.push({ + // eslint-disable-next-line i18next/no-literal-string + key: `${dependency.stableId}-placeholder-${i}`, + label: `-`, + // eslint-disable-next-line i18next/no-literal-string + groupByKey: `${dependency.stableId}-placeholder-${i}`, + sourceId: dependency.stableId!.toString(), + }); + }); + } else { + const key = classTableRowKey(dependency.stableId!, "*"); + const styles = source?.mapboxGlStyles as AnyLayer[] | undefined; + let color: string | undefined; + let colors: string[] | undefined; + + if (source && isRasterSource(source) && styles?.length) { + const rasterColors = getRasterColorsFromStyle(styles); + if (rasterColors?.length) { + colors = rasterColors; + } else { + color = extractColorForLayers(styles); + } + } else if (styles?.length) { + color = extractColorForLayers(styles); + } + + if (color !== undefined && isTransparentColor(color)) { + color = undefined; + } + + rows.push({ + key, + label: + options.customLabels?.[key] || + (multiSource + ? source?.tableOfContentsItem?.title || options.allFeaturesLabel + : options.allFeaturesLabel), + groupByKey: "*", + sourceId: dependency.stableId!.toString(), + stableId: options.stableIds?.[key], + ...(color !== undefined && { color }), + ...(colors !== undefined && { colors }), + }); + } + } else { + if (dependency.parameters?.groupBy) { + const attr = layer.attributes?.find( + (a) => a.attribute === dependency.parameters?.groupBy + ); + if (!attr) { + throw new Error( + `Attribute ${dependency.parameters?.groupBy} not found in geostats layer` + ); + } + const values = Object.keys(attr.values || {}); + const colors = extractColorsForCategories( + values, + attr, + source.mapboxGlStyles as AnyLayer[] + ); + for (const value of values) { + const key = classTableRowKey(dependency.stableId!, value); + let color: string | undefined = + colors[value] || + extractColorForLayers(source.mapboxGlStyles as AnyLayer[]); + if (color !== undefined && isTransparentColor(color)) { + color = undefined; + } + rows.push({ + key, + label: options.customLabels?.[key] || value, + groupByKey: value, + sourceId: dependency.stableId!.toString(), + stableId: options.stableIds?.[key], + color, + }); + } + } else { + const key = classTableRowKey(dependency.stableId!, "*"); + let color: string | undefined = extractColorForLayers( + source.mapboxGlStyles as AnyLayer[] + ); + if (color !== undefined && isTransparentColor(color)) { + color = undefined; + } + rows.push({ + key, + label: + options.customLabels?.[key] || + (multiSource + ? source.tableOfContentsItem?.title || options.allFeaturesLabel + : options.allFeaturesLabel), + groupByKey: "*", + sourceId: dependency.stableId!.toString(), + stableId: options.stableIds?.[key], + color, + }); + } + } + } + return rows.filter((r) => !options.excludedRowKeys?.includes(r.key)); +} + +export function combineMetricsBySource( + metrics: CompatibleSpatialMetricDetailsFragment[], + sources: OverlaySourceDetailsFragment[], + geographyId: number +): { + [sourceId: string]: { + fragments: T; + geographies: T; + }; +} { + // handle duplicates + const metricIds = new Set(metrics.map((m) => m.id)); + metrics = metrics.filter((m) => m.state === SpatialMetricState.Complete); + metrics = Array.from(metricIds) + .map((id) => metrics.find((m) => m.id === id)) + .filter(Boolean) as CompatibleSpatialMetricDetailsFragment[]; + const result: { + [sourceId: string]: { + fragments: T; + geographies: T; + }; + } = {}; + // first, gather up source ids + const sourceIds = new Set(); + for (const metric of metrics) { + if (metric.sourceUrl) { + const source = sources.find((s) => s.sourceUrl === metric.sourceUrl); + if (source) { + sourceIds.add(source.stableId); + } + } + } + // then for each sourceId, combine the metrics + for (const sourceId of sourceIds) { + const source = sources.find((s) => s.stableId === sourceId); + if (source) { + result[source.stableId] = { + fragments: combineMetricsForFragments( + metrics.filter( + (m) => + m.sourceUrl === source.sourceUrl && + subjectIsFragment(m.subject) && + m.subject.geographies.includes(geographyId) + ) as Pick[] + ) as T, + geographies: metrics.find( + (m) => + m.sourceUrl === source.sourceUrl && + subjectIsGeography(m.subject) && + m.subject.id === geographyId + ) as unknown as T, + }; + } + } + return result; +} diff --git a/packages/client/src/reports/widgets/FeatureCountTable.tsx b/packages/client/src/reports/widgets/FeatureCountTable.tsx index b7f46a41d..ea4274244 100644 --- a/packages/client/src/reports/widgets/FeatureCountTable.tsx +++ b/packages/client/src/reports/widgets/FeatureCountTable.tsx @@ -3,8 +3,6 @@ import { Trans, useTranslation } from "react-i18next"; import { MetricDependency, subjectIsFragment, - subjectIsGeography, - combineMetricsForFragments, CountMetric, Metric, } from "overlay-engine"; @@ -21,17 +19,17 @@ import { LabeledDropdown } from "./LabeledDropdown"; import { MetricLoadingDots } from "../components/MetricLoadingDots"; import { useOverlaySources } from "../hooks/useOverlaySources"; import { useNumberFormatters } from "../hooks/useNumberFormatters"; +import { SpatialMetricState } from "../../generated/graphql"; import { - extractColorForLayers, - extractColorsForCategories, -} from "../utils/colors"; -import { AnyLayer } from "mapbox-gl"; -import { GeostatsLayer } from "@seasketch/geostats-types"; + ClassTableRow, + ClassTableRowComponentSettings, + combineMetricsBySource, + getClassTableRows, +} from "./ClassTableRows"; import { - CompatibleSpatialMetricDetailsFragment, - OverlaySourceDetailsFragment, - SpatialMetricState, -} from "../../generated/graphql"; + classTableRowHasSwatch, + SwatchForClassTableRow, +} from "./SwatchForClassTableRow"; import { PaginationFooter, PaginationSetting, @@ -43,216 +41,6 @@ import ReportLayerVisibilityCheckbox from "../components/ReportLayerVisibilityCh import { LayersIcon } from "@radix-ui/react-icons"; import { useClippingGeography } from "../hooks/useClippingGeography"; -export type ClassTableRow = { - key: string; - label: string; - groupByKey: string; - sourceId: string; - color?: string; - stableId?: string; -}; - -export type ClassTableRowComponentSettings = { - /** - * A list of row keys to exclude from the table. The key must match the ClassTableRow.key value. - */ - excludedRowKeys?: string[]; - /** - * A map of row keys to custom labels. The key must match the ClassTableRow.key value. - */ - customRowLabels?: { [key: string]: string }; - /** - * A map of row keys to stable IDs. The key must match the ClassTableRow.key value. - */ - rowLinkedStableIds?: { [key: string]: string }; -}; - -/** - * Returns a list of ClassTableRows for the given dependencies and sources. In - * report widgets like FeatureCountTable and OverlappingAreasTable, the widget - * can populate these rows with metrics data for rendering as part of a table - * component. The purpose of this function is to let table widgets delegate - * responsibility for determining what rows to display, their colors, and their - * labels, based on depedencies and common configuration. Widget-specific - * functionality can then be implemented by callers. - * - * If sources aren't provided, likely because they haven't been loaded yet, the - * function will return placeholder rows. - * @param options - * @returns - */ -export function getClassTableRows(options: { - dependencies: MetricDependency[]; - sources: OverlaySourceDetailsFragment[]; - allFeaturesLabel: string; - /** - * A map of group by keys to custom labels. The key must match the - * ClassTableRow.key value. - */ - customLabels?: { [key: string]: string }; - /** - * A map of table of contents item IDs to stable IDs for showing map layer - * toggles. The key must match the ClassTableRow.sourceId value. - */ - stableIds?: { [key: string]: string }; - excludedRowKeys?: string[]; -}): ClassTableRow[] { - // console.log("getClassTableRows", options); - const rows = [] as ClassTableRow[]; - const fragmentDependencies = options.dependencies.filter( - (d) => d.subjectType === "fragments" && Boolean(d.stableId) - ); - const multiSource = - fragmentDependencies.length > 1 && options.sources.length > 1; - for (const dependency of fragmentDependencies) { - const source = options.sources.find( - (s) => s.stableId === dependency.stableId - ); - const layer = source?.geostats?.layers?.[0] as GeostatsLayer | undefined; - if (!source || !layer) { - if (dependency.parameters?.groupBy) { - [1, 2, 3].forEach((i) => { - rows.push({ - // eslint-disable-next-line i18next/no-literal-string - key: `${dependency.stableId}-placeholder-${i}`, - label: `-`, - // eslint-disable-next-line i18next/no-literal-string - groupByKey: `${dependency.stableId}-placeholder-${i}`, - sourceId: dependency.stableId!.toString(), - }); - }); - } else { - const key = classTableRowKey(dependency.stableId!, "*"); - rows.push({ - key, - label: options.customLabels?.[key] || options.allFeaturesLabel, - groupByKey: "*", - sourceId: dependency.stableId!.toString(), - stableId: options.stableIds?.[key], - }); - } - } else { - if (dependency.parameters?.groupBy) { - const attr = layer.attributes?.find( - (a) => a.attribute === dependency.parameters?.groupBy - ); - if (!attr) { - throw new Error( - `Attribute ${dependency.parameters?.groupBy} not found in geostats layer` - ); - } - const values = Object.keys(attr.values || {}); - const colors = extractColorsForCategories( - values, - attr, - source.mapboxGlStyles as AnyLayer[] - ); - for (const value of values) { - const key = classTableRowKey(dependency.stableId!, value); - let color: string | undefined = - colors[value] || - extractColorForLayers(source.mapboxGlStyles as AnyLayer[]); - if (color === "transparent" || color === "#00000000") { - color = undefined; - } - rows.push({ - key, - label: options.customLabels?.[key] || value, - groupByKey: value, - sourceId: dependency.stableId!.toString(), - stableId: options.stableIds?.[key], - color, - }); - } - } else { - const key = classTableRowKey(dependency.stableId!, "*"); - let color: string | undefined = extractColorForLayers( - source.mapboxGlStyles as AnyLayer[] - ); - if (color === "transparent" || color === "#00000000") { - color = undefined; - } - rows.push({ - key, - label: - options.customLabels?.[key] || - (multiSource - ? source.tableOfContentsItem?.title || options.allFeaturesLabel - : options.allFeaturesLabel), - groupByKey: "*", - sourceId: dependency.stableId!.toString(), - stableId: options.stableIds?.[key], - color, - }); - } - } - } - // console.log( - // "returning rows", - // rows.filter((r) => !options.excludedRowKeys?.includes(r.key)) - // ); - return rows.filter((r) => !options.excludedRowKeys?.includes(r.key)); -} - -export function classTableRowKey(stableId: string, groupByKey?: string) { - return `${stableId}-${groupByKey || "*"}`; -} - -export function combineMetricsBySource( - metrics: CompatibleSpatialMetricDetailsFragment[], - sources: OverlaySourceDetailsFragment[], - geographyId: number -): { - [sourceId: string]: { - fragments: T; - geographies: T; - }; -} { - // handle duplicates - const metricIds = new Set(metrics.map((m) => m.id)); - metrics = metrics.filter((m) => m.state === SpatialMetricState.Complete); - metrics = Array.from(metricIds) - .map((id) => metrics.find((m) => m.id === id)) - .filter(Boolean) as CompatibleSpatialMetricDetailsFragment[]; - const result: { - [sourceId: string]: { - fragments: T; - geographies: T; - }; - } = {}; - // first, gather up source ids - const sourceIds = new Set(); - for (const metric of metrics) { - if (metric.sourceUrl) { - const source = sources.find((s) => s.sourceUrl === metric.sourceUrl); - if (source) { - sourceIds.add(source.stableId); - } - } - } - // then for each sourceId, combine the metrics - for (const sourceId of sourceIds) { - const source = sources.find((s) => s.stableId === sourceId); - if (source) { - result[source.stableId] = { - fragments: combineMetricsForFragments( - metrics.filter( - (m) => - m.sourceUrl === source.sourceUrl && subjectIsFragment(m.subject) - ) as Pick[] - ) as T, - geographies: metrics.find( - (m) => - m.sourceUrl === source.sourceUrl && - subjectIsGeography(m.subject) && - m.subject.id === geographyId - ) as unknown as T, - }; - } - } - return result; -} - type FeatureCountTableSettings = { showZeroCountCategories?: boolean; sortBy?: "count" | "name"; @@ -382,7 +170,7 @@ export const FeatureCountTable: ReportWidget = ({ } = usePagination(displayRows, rowsPerPage); const hasAnyColor = useMemo( - () => showColorSwatches && rows.some((row) => row.color), + () => showColorSwatches && rows.some(classTableRowHasSwatch), [rows, showColorSwatches] ); const hasVisibilityColumn = useMemo( @@ -433,14 +221,12 @@ export const FeatureCountTable: ReportWidget = ({ )}
    {paginatedRows.map((row) => { - const color = row.color; const percent = !loading && typeof row.geographyTotal === "number" && row.geographyTotal > 0 ? row.count / row.geographyTotal : undefined; - const hasColor = showColorSwatches && color; const stableId = row.stableId || componentSettings.rowLinkedStableIds?.[row.key] || @@ -463,15 +249,7 @@ export const FeatureCountTable: ReportWidget = ({ )}
    )} - {hasColor && ( -
    - -
    - )} + {showColorSwatches && }
    showColorSwatches && rows.some((row) => row.color), + () => showColorSwatches && rows.some(classTableRowHasSwatch), [rows, showColorSwatches] ); const hasVisibilityColumn = useMemo( @@ -176,7 +180,6 @@ export const FeaturePresenceTable: ReportWidget<
    {paginatedRows.map((row) => { - const color = row.color; const stableId = row.stableId || componentSettings.rowLinkedStableIds?.[row.key] || @@ -198,15 +201,7 @@ export const FeaturePresenceTable: ReportWidget< )} )} - {showColorSwatches && color && ( -
    - -
    - )} + {showColorSwatches && }
    = ({ const formatted = formatters.decimal(value); return rasterUnitLabel ? `${formatted} ${rasterUnitLabel}` : formatted; } + case "geography_raster_stats": { + const geographyId = + componentSettings.geographyId === "auto" || + componentSettings.geographyId === undefined + ? clippingGeography?.id + : componentSettings.geographyId; + if (geographyId === undefined) { + throw new Error("Primary geography not found."); + } + const geographyRasterMetric = metrics.find( + (m) => + m.type === "raster_stats" && + subjectIsGeography(m.subject) && + m.subject.id === geographyId + ) as RasterStats | undefined; + if (!geographyRasterMetric) { + throw new Error("Geography raster stats not found in metrics."); + } + const bands = geographyRasterMetric.value.bands; + if (!bands || bands.length === 0) { + throw new Error("No raster band data available for this geography."); + } + const value = bands[0][componentSettings?.rasterStat || "mean"]; + const formatted = formatters.decimal(value); + return rasterUnitLabel ? `${formatted} ${rasterUnitLabel}` : formatted; + } + case "geography_proportion_captured": { + const geographyId = + componentSettings.geographyId === "auto" || + componentSettings.geographyId === undefined + ? clippingGeography?.id + : componentSettings.geographyId; + if (geographyId === undefined) { + throw new Error("Primary geography not found."); + } + // Sketch sum: combine fragment raster_stats metrics + const fragmentRasterMetrics = metrics.filter( + (m) => m.type === "raster_stats" && subjectIsFragment(m.subject) + ); + if (fragmentRasterMetrics.length === 0) { + throw new Error("Sketch raster stats not found in metrics."); + } + const combinedSketch = combineMetricsForFragments( + fragmentRasterMetrics as Pick[] + ) as RasterStats; + const sketchBands = combinedSketch.value.bands; + if (!sketchBands || sketchBands.length === 0) { + throw new Error("No raster band data available for sketch."); + } + const sketchSum = sketchBands[0].sum; + // Geography sum: find the raster_stats metric for the target geography + const geographyRasterMetric = metrics.find( + (m) => + m.type === "raster_stats" && + subjectIsGeography(m.subject) && + m.subject.id === geographyId + ) as RasterStats | undefined; + if (!geographyRasterMetric) { + throw new Error("Geography raster stats not found in metrics."); + } + const geographyBands = geographyRasterMetric.value.bands; + if (!geographyBands || geographyBands.length === 0) { + throw new Error("No raster band data available for this geography."); + } + const geographySum = geographyBands[0].sum; + if (!geographySum) { + return formatters.percent(0); + } + return formatters.percent(sketchSum / geographySum); + } default: // eslint-disable-next-line i18next/no-literal-string errors.push(`Unsupported presentation: ${presentation}`); @@ -659,7 +731,7 @@ function inlineMetricPropsEqual( export const InlineMetric = memo(_InlineMetric, inlineMetricPropsEqual); -function GeographySelector({ +export function GeographySelector({ geographies, clippingGeography, value, @@ -1018,7 +1090,8 @@ export const InlineMetricTooltipControls: ReportWidgetTooltipControls = ({ } /> )} - {presentation === "raster_stats" && ( + {/* {(presentation === "raster_stats" || + presentation === "geography_raster_stats") && ( - )} - {presentation === "geography_overlay_area" && ( + )} */} + {(presentation === "geography_overlay_area" || + presentation === "geography_raster_stats" || + presentation === "geography_proportion_captured") && ( handleStatChange(val as ColumnValuesStatKey)} /> )} - {presentation === "raster_stats" && ( + {(presentation === "raster_stats" || + presentation === "geography_raster_stats") && ( ; +}) { + const { t } = useTranslation("reports"); + const source = sources.find((s) => s.stableId === row.sourceId); + + const fragmentMetrics = useMemo( + () => + !source + ? [] + : metrics.filter( + (m) => + m.sourceUrl === source.sourceUrl && + subjectIsFragment(m.subject) && + (m.subject as { geographies: number[] }).geographies.includes( + primaryGeographyId + ) + ), + [metrics, source, primaryGeographyId] + ); + + const geographyMetric = useMemo( + () => + !source + ? undefined + : metrics.find( + (m) => + m.sourceUrl === source.sourceUrl && + subjectIsGeography(m.subject) && + (m.subject as { id: number }).id === primaryGeographyId + ), + [metrics, source, primaryGeographyId] + ); + + return ( + + + + + + +
    +

    + {t( + "The percent within exceeds 100% because the overlap area is larger than the geography total. This can happen when sketch geometries extend beyond the geography boundary." + )} +

    + + + + + + + + + + + + + + + +
    {t("Overlap")} + {formatters.area(row.overlap)} +
    + {t("Geography total")} + + {formatters.area(row.geographyTotal!)} +
    + {t("Percent within")} + + {formatters.percent(percent)} +
    + {geographyMetric && ( +
    +

    + {t("Geography metric (id={{id}})", { + id: geographyMetric.id, + })} +

    +
    +                  {JSON.stringify(
    +                    (geographyMetric.value as Record)?.[
    +                      row.groupByKey
    +                    ] ?? geographyMetric.value,
    +                    null,
    +                    2
    +                  )}
    +                
    +
    + )} + {fragmentMetrics.length > 0 && ( +
    +

    + {t("Fragment metrics ({{count}})", { + count: fragmentMetrics.length, + })} +

    + {fragmentMetrics.map((m) => ( +
    +

    + {t("id={{id}} hash={{hash}}…", { + id: m.id, + hash: subjectIsFragment(m.subject) + ? (m.subject as { hash: string }).hash.slice(0, 8) + : "", + })} +

    +
    +                      {JSON.stringify(
    +                        (m.value as Record)?.[
    +                          row.groupByKey
    +                        ] ?? m.value,
    +                        null,
    +                        2
    +                      )}
    +                    
    +
    + ))} +
    + )} +
    + +
    +
    +
    + ); +} + export const OverlappingAreasTable: ReportWidget< OverlappingAreasTableSettings > = ({ @@ -238,8 +398,10 @@ export const OverlappingAreasTable: ReportWidget< ? row.overlap / row.geographyTotal : undefined; if (percent && percent > 1.05) { - throw new Error( - `Percent is greater than 100%. Value: ${percent * 100}%` + console.error( + new Error( + `Percent is greater than 100%. Value: ${percent * 100}%` + ) ); } return ( @@ -256,15 +418,7 @@ export const OverlappingAreasTable: ReportWidget< ) : null}
    )} - {showColorSwatches && row.color && ( -
    - -
    - )} + {showColorSwatches && }
    {row.label} @@ -279,6 +433,18 @@ export const OverlappingAreasTable: ReportWidget<
    {showPercentColumn && (
    + {typeof percent === "number" && + percent > 1.05 && + primaryGeographyId !== undefined && ( + + )} {loading ? ( ) : typeof percent === "number" ? ( @@ -294,7 +460,9 @@ export const OverlappingAreasTable: ReportWidget< row.color)} + includeColorColumn={ + showColorSwatches && rows.some(classTableRowHasSwatch) + } showPercentColumn={showPercentColumn} />
    diff --git a/packages/client/src/reports/widgets/RasterProportionTable.tsx b/packages/client/src/reports/widgets/RasterProportionTable.tsx new file mode 100644 index 000000000..76faf7b54 --- /dev/null +++ b/packages/client/src/reports/widgets/RasterProportionTable.tsx @@ -0,0 +1,336 @@ +import { useMemo } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { MetricDependency, RasterStats } from "overlay-engine"; +import { ReportWidget, TableHeadingsEditor } from "./widgets"; +import { + ReportWidgetTooltipControls, + TooltipMorePopover, +} from "../../editor/TooltipMenu"; +import { useNumberFormatters } from "../hooks/useNumberFormatters"; +import { NumberRoundingControl } from "./NumberRoundingControl"; +import { MetricLoadingDots } from "../components/MetricLoadingDots"; +import { useOverlaySources } from "../hooks/useOverlaySources"; +import { + PaginationFooter, + PaginationSetting, + TablePaddingRows, +} from "./Pagination"; +import { usePagination } from "../hooks/usePagination"; +import { + ClassTableRow, + ClassTableRowComponentSettings, + combineMetricsBySource, + getClassTableRows, +} from "./ClassTableRows"; +import { + classTableRowHasSwatch, + SwatchForClassTableRow, +} from "./SwatchForClassTableRow"; +import { ClassRowSettingsPopover } from "./ClassRowSettingsPopover"; +import { LabeledDropdown } from "./LabeledDropdown"; +import ReportLayerVisibilityCheckbox from "../components/ReportLayerVisibilityCheckbox"; +import { LayersIcon } from "@radix-ui/react-icons"; +import { useClippingGeography } from "../hooks/useClippingGeography"; +import { GeographySelector } from "./InlineMetric"; +import { useBaseReportContext } from "../context/BaseReportContext"; + +type RasterProportionTableSettings = { + geographyId?: number | "auto"; + sortBy?: "value" | "name"; + minimumFractionDigits?: number; + rowsPerPage?: number; + nameLabel?: string; + valueLabel?: string; + showZeroRows?: boolean; + hideColorSwatches?: boolean; +} & ClassTableRowComponentSettings; + +type ProportionRow = ClassTableRow & { + sketchSum: number; + geographySum: number; +}; + +export const RasterProportionTable: ReportWidget< + RasterProportionTableSettings +> = ({ + metrics, + componentSettings, + sources, + loading, + dependencies, + sketchClass, + geographies, +}) => { + const clippingGeography = useClippingGeography(sketchClass, geographies); + const { t } = useTranslation("reports"); + + const geographyId: number | undefined = + componentSettings.geographyId === "auto" || + componentSettings.geographyId === undefined + ? clippingGeography?.id + : componentSettings.geographyId; + + const sortBy = componentSettings.sortBy || "name"; + const rowsPerPage = componentSettings.rowsPerPage ?? 10; + const showZeroRows = componentSettings.showZeroRows ?? true; + const showColorSwatches = !componentSettings.hideColorSwatches; + const nameLabel = componentSettings.nameLabel || t("Name"); + const valueLabel = componentSettings.valueLabel || t("% Captured"); + + const formatters = useNumberFormatters({ + minimumFractionDigits: componentSettings.minimumFractionDigits, + }); + + const rows = useMemo(() => { + const classRows = getClassTableRows({ + dependencies: dependencies || [], + sources, + customLabels: componentSettings.customRowLabels, + allFeaturesLabel: t("All features"), + stableIds: componentSettings.rowLinkedStableIds, + excludedRowKeys: componentSettings.excludedRowKeys, + }); + + if (sources.length === 0 || metrics.length === 0 || loading) { + return classRows.map((r) => ({ + ...r, + sketchSum: NaN, + geographySum: NaN, + })); + } + + if (!geographyId) { + throw new Error("Primary geography not found."); + } + + const combinedMetrics = combineMetricsBySource( + metrics, + sources, + geographyId + ); + + let rows = classRows.map((r) => { + const combinedForSource = combinedMetrics[r.sourceId]; + const sketchBands = combinedForSource?.fragments?.value?.bands; + const geographyBands = combinedForSource?.geographies?.value?.bands; + const sketchSum = sketchBands?.[0]?.sum ?? 0; + const geographySum = geographyBands?.[0]?.sum ?? 0; + return { + ...r, + sketchSum, + geographySum, + }; + }); + + if (sortBy === "name") { + rows = rows.sort((a, b) => a.label.localeCompare(b.label)); + } else { + rows = rows.sort((a, b) => { + const aPercent = a.geographySum > 0 ? a.sketchSum / a.geographySum : 0; + const bPercent = b.geographySum > 0 ? b.sketchSum / b.geographySum : 0; + return bPercent - aPercent; + }); + } + + if (!showZeroRows) { + rows = rows.filter((r) => r.sketchSum > 0); + } + + return rows; + }, [ + metrics, + dependencies, + sources, + geographyId, + componentSettings.customRowLabels, + componentSettings.rowLinkedStableIds, + componentSettings.excludedRowKeys, + showZeroRows, + sortBy, + t, + loading, + ]); + + const hasVisibilityColumn = useMemo( + () => rows.some((r) => r.stableId), + [rows] + ); + + const { + currentPage, + setCurrentPage, + paginatedItems: paginatedRows, + paddingRowsCount, + showPagination, + totalPages, + totalRows, + pageBounds, + } = usePagination(rows, rowsPerPage); + + return ( +
    +
    + {/* Header row */} +
    + {hasVisibilityColumn && ( +
    + +
    + )} +
    + {nameLabel} +
    +
    + {valueLabel} +
    +
    + {paginatedRows.map((row) => { + const percent = + !loading && row.geographySum > 0 + ? row.sketchSum / row.geographySum + : 0; + return ( +
    + {hasVisibilityColumn && ( +
    + {row.stableId ? ( + + ) : null} +
    + )} + {showColorSwatches && } +
    + + {row.label} + +
    +
    + {loading ? : formatters.percent(percent)} +
    +
    + ); + })} + +
    + {!loading && rows.length === 0 && ( +
    + No data available. +
    + )} + {showPagination && ( + + )} +
    + ); +}; + +export const RasterProportionTableTooltipControls: ReportWidgetTooltipControls = + ({ + node, + onUpdate, + onUpdateDependencyParameters, + onUpdateAllDependencies, + }) => { + const { t } = useTranslation("admin:reports"); + const dependencies = useMemo( + () => (node.attrs?.metrics || []) as MetricDependency[], + [node.attrs?.metrics] + ); + const settings: RasterProportionTableSettings = useMemo( + () => node.attrs?.componentSettings || {}, + [node.attrs?.componentSettings] + ); + + const sortBy = settings.sortBy || "name"; + const showZeroRows = settings.showZeroRows ?? true; + const rowsPerPage = settings.rowsPerPage ?? 10; + + const { filteredSources: sources } = useOverlaySources(dependencies); + + const { geographies, sketchClass } = useBaseReportContext(); + const clippingGeography = useClippingGeography(sketchClass, geographies); + + const handleUpdate = (patch: Partial) => { + onUpdate({ + componentSettings: { + ...settings, + ...patch, + }, + }); + }; + + const sortOptions = [ + { value: "name", label: t("Name") }, + { value: "value", label: t("% Captured") }, + ]; + + return ( +
    + handleUpdate({ geographyId })} + t={t} + /> + + handleUpdate({ minimumFractionDigits }) + } + /> + handleUpdate({ sortBy: val as "value" | "name" })} + /> + handleUpdate(patch)} + dependencies={dependencies || []} + sources={sources} + onUpdateDependencyParameters={onUpdateDependencyParameters} + onUpdateAllDependencies={onUpdateAllDependencies} + t={t} + allowedGeometryTypes={["SingleBandRaster"]} + hideGroupBy={true} + showZeros={showZeroRows} + onShowZerosChange={(next) => handleUpdate({ showZeroRows: next })} + showColorSwatches={!settings.hideColorSwatches} + onShowColorSwatchesChange={(next) => + handleUpdate({ hideColorSwatches: next ? undefined : true }) + } + /> + + + handleUpdate({ rowsPerPage: next })} + /> + +
    + ); + }; diff --git a/packages/client/src/reports/widgets/SwatchForClassTableRow.tsx b/packages/client/src/reports/widgets/SwatchForClassTableRow.tsx new file mode 100644 index 000000000..d577a7eea --- /dev/null +++ b/packages/client/src/reports/widgets/SwatchForClassTableRow.tsx @@ -0,0 +1,75 @@ +import type { ClassTableRow } from "./ClassTableRows"; + +/** + * Returns true if the row has any color information to show in a swatch + * (single color or colors array). + */ +export function classTableRowHasSwatch(row: ClassTableRow): boolean { + return !!(row.color || (row.colors && row.colors.length > 0)); +} + +type SwatchForClassTableRowProps = { + row: ClassTableRow; +}; + +/** + * Renders a small color swatch for a ClassTableRow. Returns null if the row + * has no color info. Chooses presentation automatically: single solid color + * for `row.color`, or a grid of all stops for `row.colors`. + */ +export function SwatchForClassTableRow({ row }: SwatchForClassTableRowProps) { + if (!classTableRowHasSwatch(row)) { + return null; + } + + if (row.color) { + return ( +
    + +
    + ); + } + + if (row.colors && row.colors.length > 0) { + const colors = row.colors; + const cols = Math.ceil(Math.sqrt(colors.length)); + const rows = Math.ceil(colors.length / cols); + const cellCount = cols * rows; + const paddedColors = + cellCount > colors.length + ? [ + ...colors, + ...Array(cellCount - colors.length).fill(colors[colors.length - 1]), + ] + : colors; + + return ( +
    + + {paddedColors.map((c, i) => ( + + ))} + +
    + ); + } + + return null; +} diff --git a/packages/client/src/reports/widgets/widgets.tsx b/packages/client/src/reports/widgets/widgets.tsx index 88f59647b..43a0f9144 100644 --- a/packages/client/src/reports/widgets/widgets.tsx +++ b/packages/client/src/reports/widgets/widgets.tsx @@ -74,6 +74,10 @@ import { RasterStatisticsTable, RasterStatisticsTableTooltipControls, } from "./RasterStatisticsTable"; +import { + RasterProportionTable, + RasterProportionTableTooltipControls, +} from "./RasterProportionTable"; import { Mark, Node } from "prosemirror-model"; import { useWidgetDependencies } from "../hooks/useWidgetDependencies"; import { ReportUIStateContext } from "../context/ReportUIStateContext"; @@ -328,6 +332,10 @@ const memoizedWidgets: Record = { RasterStatisticsTable, "RasterStatisticsTable" ), + RasterProportionTable: memoWidget( + RasterProportionTable, + "RasterProportionTable" + ), InlineLayerToggle: memoWidget(InlineLayerToggle, "InlineLayerToggle"), BlockLayerToggle: memoWidget(BlockLayerToggle, "BlockLayerToggle"), }; @@ -492,6 +500,8 @@ export const ReportWidgetTooltipControlsRouter: ReportWidgetTooltipControls = ( return ; case "RasterStatisticsTable": return ; + case "RasterProportionTable": + return ; case "BlockLayerToggle": return ; case "InlineLayerToggle": @@ -585,6 +595,8 @@ export const ReportWidgetNodeViewRouter: FC = (props: any) => { return ; case "RasterStatisticsTable": return ; + case "RasterProportionTable": + return ; case "InlineLayerToggle": return ; case "BlockLayerToggle": @@ -1007,13 +1019,12 @@ export function buildReportCommandGroups({ label: "Inline Metrics", items: [], }; - // Raster handling can be added here if needed inlineGroup.items.push({ // eslint-disable-next-line i18next/no-literal-string id: `overlay-layer-${tocId}-inline-band-stats`, label: "Raster Statistics", description: - "Insert a raster statistic such as mean, min, max, or count.", + "Insert a raster statistic such as mean, min, max, or count for the sketch.", screenshotSrc: "/slashCommands/inline-raster-metric.png", run: (state, dispatch, view) => { return insertInlineMetric(view, state.selection.ranges[0], { @@ -1032,6 +1043,58 @@ export function buildReportCommandGroups({ }); }, }); + inlineGroup.items.push({ + // eslint-disable-next-line i18next/no-literal-string + id: `overlay-layer-${tocId}-inline-geography-band-stats`, + label: "Geography Raster Statistics", + description: + "Insert a raster statistic (mean, min, max, or count) computed for an entire geography rather than just the sketch.", + screenshotSrc: "/slashCommands/inline-raster-metric.png", + run: (state, dispatch, view) => { + return insertInlineMetric(view, state.selection.ranges[0], { + type: "InlineMetric", + metrics: [ + { + type: "raster_stats", + subjectType: "geographies", + stableId, + }, + ], + componentSettings: { + presentation: "geography_raster_stats", + rasterStat: "mean", + }, + }); + }, + }); + inlineGroup.items.push({ + // eslint-disable-next-line i18next/no-literal-string + id: `overlay-layer-${tocId}-inline-geography-proportion-captured`, + label: "Geography Proportion Captured", + description: + "Percentage of the total raster sum within a geography that falls inside the sketch.", + screenshotSrc: "/slashCommands/percent-geography.png", + run: (state, dispatch, view) => { + return insertInlineMetric(view, state.selection.ranges[0], { + type: "InlineMetric", + metrics: [ + { + type: "raster_stats", + subjectType: "fragments", + stableId, + }, + { + type: "raster_stats", + subjectType: "geographies", + stableId, + }, + ], + componentSettings: { + presentation: "geography_proportion_captured", + }, + }); + }, + }); const blockGroup: CommandPaletteGroup = { // eslint-disable-next-line i18next/no-literal-string id: `overlay-layer-${tocId}-block-group`, @@ -1094,6 +1157,32 @@ export function buildReportCommandGroups({ }); }, }); + blockGroup.items.push({ + // eslint-disable-next-line i18next/no-literal-string + id: `overlay-layer-${tocId}-raster-proportion-table`, + label: "Raster Proportion Captured Table", + description: + "Table showing what proportion of each raster layer's total value within a geography is captured by the sketch.", + screenshotSrc: "/slashCommands/raster-proportion.png", + run: (state, dispatch, view) => { + return insertBlockMetric(view, state.selection.ranges[0], { + type: "RasterProportionTable", + metrics: [ + { + type: "raster_stats", + subjectType: "fragments", + stableId, + }, + { + type: "raster_stats", + subjectType: "geographies", + stableId, + }, + ], + componentSettings: {}, + }); + }, + }); childGroups = [inlineGroup, blockGroup]; } else if ( "bands" in source.geostats && diff --git a/packages/geostats-types/dist/lib/index.d.ts.map b/packages/geostats-types/dist/lib/index.d.ts.map index 484b7961b..79cbb2ba9 100644 --- a/packages/geostats-types/dist/lib/index.d.ts.map +++ b/packages/geostats-types/dist/lib/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../lib/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../lib/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/geostats-types/dist/lib/index.js b/packages/geostats-types/dist/lib/index.js index 4f85afc4c..4d0cf11f4 100644 --- a/packages/geostats-types/dist/lib/index.js +++ b/packages/geostats-types/dist/lib/index.js @@ -40,6 +40,18 @@ function isRasterInfo(info) { return info.bands !== undefined; } function isGeostatsLayer(data) { - return (!Array.isArray(data) && data.attributes !== undefined); + if (!data) { + return false; + } + if (Array.isArray(data)) { + return false; + } + if (typeof data !== "object") { + return false; + } + if (!("attributes" in data)) { + return false; + } + return true; } //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/geostats-types/dist/lib/index.js.map b/packages/geostats-types/dist/lib/index.js.map index 662d2d3e1..144fb1800 100644 --- a/packages/geostats-types/dist/lib/index.js.map +++ b/packages/geostats-types/dist/lib/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../lib/index.ts"],"names":[],"mappings":";;;AAiGA,gEAIC;AAwDD,sDAQC;AAED,8DAIC;AAqFD,oCAIC;AAED,0CAMC;AA3KD,SAAgB,0BAA0B,CACxC,IAAuB;IAEvB,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ,CAAC;AAChC,CAAC;AAwDD,SAAgB,qBAAqB,CACnC,KAA0C;IAE1C,IAAI,iBAAiB,IAAI,KAAK,IAAK,KAAa,CAAC,eAAe,EAAE,CAAC;QACjE,OAAQ,KAAuB,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,aAAa,KAAK,SAAS,CAAC;IAC5E,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,CAAC,QAAQ,IAAI,KAAK,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC;AAED,SAAgB,yBAAyB,CACvC,IAAiD;IAEjD,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AACpC,CAAC;AAyDD;;;;;;;;;GASG;AACH,IAAY,2BAIX;AAJD,WAAY,2BAA2B;IACrC,2FAAa,CAAA;IACb,yFAAY,CAAA;IACZ,2EAAK,CAAA;AACP,CAAC,EAJW,2BAA2B,2CAA3B,2BAA2B,QAItC;AAcD,SAAgB,YAAY,CAC1B,IAAsC;IAEtC,OAAQ,IAAmB,CAAC,KAAK,KAAK,SAAS,CAAC;AAClD,CAAC;AAED,SAAgB,eAAe,CAC7B,IAAsC;IAEtC,OAAO,CACL,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAK,IAAsB,CAAC,UAAU,KAAK,SAAS,CACzE,CAAC;AACJ,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../lib/index.ts"],"names":[],"mappings":";;;AAiGA,gEAIC;AAwDD,sDAQC;AAED,8DAIC;AAqFD,oCAIC;AAED,0CAgBC;AArLD,SAAgB,0BAA0B,CACxC,IAAuB;IAEvB,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ,CAAC;AAChC,CAAC;AAwDD,SAAgB,qBAAqB,CACnC,KAA0C;IAE1C,IAAI,iBAAiB,IAAI,KAAK,IAAK,KAAa,CAAC,eAAe,EAAE,CAAC;QACjE,OAAQ,KAAuB,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,aAAa,KAAK,SAAS,CAAC;IAC5E,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,CAAC,QAAQ,IAAI,KAAK,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC;AAED,SAAgB,yBAAyB,CACvC,IAAiD;IAEjD,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AACpC,CAAC;AAyDD;;;;;;;;;GASG;AACH,IAAY,2BAIX;AAJD,WAAY,2BAA2B;IACrC,2FAAa,CAAA;IACb,yFAAY,CAAA;IACZ,2EAAK,CAAA;AACP,CAAC,EAJW,2BAA2B,2CAA3B,2BAA2B,QAItC;AAcD,SAAgB,YAAY,CAC1B,IAAsC;IAEtC,OAAQ,IAAmB,CAAC,KAAK,KAAK,SAAS,CAAC;AAClD,CAAC;AAED,SAAgB,eAAe,CAC7B,IAAsC;IAEtC,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACxB,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC7B,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CAAC,CAAC,YAAY,IAAI,IAAI,CAAC,EAAE,CAAC;QAC5B,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"} \ No newline at end of file diff --git a/packages/geostats-types/lib/index.d.ts b/packages/geostats-types/lib/index.d.ts deleted file mode 100644 index 47cd31a56..000000000 --- a/packages/geostats-types/lib/index.d.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { GeoJsonGeometryTypes } from "geojson"; -/** - * Attribute type as translated to a javacsript type - */ -export type GeostatsAttributeType = "string" | "number" | "boolean" | "null" | "mixed" | "object" | "array"; -/** - * A bucket is a tuple of [break, count]. Each bucket has a count of the number - * of features between the break and the next break. The last bucket will have - * a null count. - */ -export type Bucket = [number, number | null]; -/** - * A set of buckets for a given number of breaks. This way cartography - * interfaces can give the user an option to choose the number of breaks - */ -export type Buckets = { - [numBreaks: number]: Bucket[]; -}; -export interface BaseGeostatsAttribute { - /** Name of the attribute */ - attribute: string; - /** Number of rows with this value specified. Nulls don't count */ - count: number; - /** Number of distinct values found for this attribute in the data source */ - countDistinct?: number; - /** Type of the attribute */ - type: GeostatsAttributeType; - /** - * Strict mapbox/geostats stringifies objects and arrays, which isn't helpful - * when dealing with sketch classes. GeoJSON can contain arrays and objects in - * properties, and so can MVT (it's not strictly specified in the spec). - * https://docs.mapbox.com/data/tilesets/guides/vector-tiles-standards/#how-to-encode-attributes-that-arent-strings-or-numbers - */ - typeArrayOf?: GeostatsAttributeType; - /** Minimum value for numeric attributes */ - min?: number; - /** Maximum value for numeric attributes */ - max?: number; - /** - * An object with keys representing each unique value for the attribute, - * along with a a count of the number of times it occurs - */ - values: { - [key: string]: number; - }; -} -/** - * Numeric attributes have additional statistics to facilitate cartographic - * rendering. - */ -export interface NumericGeostatsAttribute extends BaseGeostatsAttribute { - type: "number"; - stats: { - /** Mean value of the attribute */ - avg: number; - /** - * Equal Interval class breaks - */ - equalInterval: Buckets; - /** Jenks or CKmeans breaks */ - naturalBreaks: Buckets; - /** Quantile breaks */ - quantiles: Buckets; - /** Geometric Interval breaks */ - geometricInterval: Buckets; - /** Standard deviation breaks */ - standardDeviations: Buckets; - /** - * Histogram is represented as a sorted set of [value, count] records. Each - * histogram has 50 buckets. - */ - histogram: [number, number | null][]; - /** Standard deviation of the attribute */ - stdev: number; - }; -} -export type GeostatsAttribute = BaseGeostatsAttribute | NumericGeostatsAttribute; -export type LegacyGeostatsAttribute = Omit & { - values: (string | number | boolean | null)[]; - quantiles?: number[]; -}; -export declare function isNumericGeostatsAttribute(attr: GeostatsAttribute): attr is NumericGeostatsAttribute; -export declare enum MetadataType { - ISO19139 = "ISO19139", - FGDC = "FGDC" -} -export type GeostatsMetadata = { - /** - * metadata for the layer summarized as a prosemirror document - */ - doc: any; - /** - * Attribution for the layer - */ - attribution?: string; - type: MetadataType; - /** - * Suggested title based on the metadata - */ - title?: string; -}; -export interface GeostatsLayer { - /** - * Name for the layer - */ - layer: string; - /** - * Number of features in the layer - */ - count: number; - /** - * Geometry type for the layer - */ - geometry: GeoJsonGeometryTypes | "Unknown"; - hasZ: boolean; - /** - * Number of attributes in the layer - */ - attributeCount: number; - /** - * List of attributes in the layer - */ - attributes: GeostatsAttribute[]; - bounds?: number[]; - metadata?: GeostatsMetadata; -} -export type LegacyGeostatsLayer = Omit & { - attributes: LegacyGeostatsAttribute[]; -}; -export declare function isLegacyGeostatsLayer(layer: LegacyGeostatsLayer | GeostatsLayer): layer is LegacyGeostatsLayer; -export declare function isLegacyGeostatsAttribute(attr: LegacyGeostatsAttribute | GeostatsAttribute): attr is LegacyGeostatsAttribute; -/** - * A bucket is a tuple of [break, fraction]. Each bucket has the fraction of the - * number of features between the break and the next break. The last bucket will - * have a null fraction. - */ -export type RasterBucket = [number, number | null]; -/** - * A set of buckets for a given number of breaks. This way cartography - * interfaces can give the user an option to choose the number of breaks - */ -export type RasterBuckets = { - [numBreaks: number]: RasterBucket[]; -}; -/** - * A color table entry is a tuple of [value, color]. The value references the - * raster value and the color is a CSS color string. Raster values should match - * those in the stats.categories array. - */ -export type ColorTableEntry = [number, string]; -export type RasterBandInfo = { - name: string; - colorInterpretation: "Red" | "Green" | "Blue" | "Alpha" | "Gray" | string | null; - base: number; - count: number; - minimum: number; - maximum: number; - interval: number; - noDataValue: number | null; - scale: number | null; - offset: number | null; - stats: { - mean: number; - stdev: number; - equalInterval: RasterBuckets; - geometricInterval: RasterBuckets; - naturalBreaks: RasterBuckets; - quantiles: RasterBuckets; - standardDeviations: RasterBuckets; - histogram: RasterBucket[]; - categories: RasterBucket[]; - }; - metadata?: { - [key: string]: string; - }; - colorTable?: ColorTableEntry[]; - bounds?: number[]; -}; -/** - * SuggestedRasterPresentation is a hint to the client on how to present the - * raster data. This can be used to determine the default visualization type for - * the raster data. - * - * - "categorical" is used for rasters with a color interpretation of "Palette", - * or which have a small number of unique values - * - "continuous" is used for rasters with a color interpretation of "Gray" - * - "rgb" is used for rasters which can be simply presented as an RGB image - */ -export declare enum SuggestedRasterPresentation { - "categorical" = 0, - "continuous" = 1, - "rgb" = 2 -} -export interface RasterInfo { - bands: RasterBandInfo[]; - presentation: SuggestedRasterPresentation; - representativeColorsForRGB?: [number, number, number][]; - metadata?: { - [key: string]: string; - }; - /** - * Indicates that the value should be derived using a simpler mapbox - * expression. [0, 0,255, base] vs [65536, 256, 1, base] - */ - byteEncoding?: boolean; -} -export declare function isRasterInfo(info: RasterInfo | GeostatsLayer | any): info is RasterInfo; -export declare function isGeostatsLayer(data: RasterInfo | GeostatsLayer | any): data is GeostatsLayer; diff --git a/packages/geostats-types/lib/index.js b/packages/geostats-types/lib/index.js deleted file mode 100644 index 84451ad94..000000000 --- a/packages/geostats-types/lib/index.js +++ /dev/null @@ -1,44 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.SuggestedRasterPresentation = void 0; -exports.isNumericGeostatsAttribute = isNumericGeostatsAttribute; -exports.isLegacyGeostatsLayer = isLegacyGeostatsLayer; -exports.isLegacyGeostatsAttribute = isLegacyGeostatsAttribute; -exports.isRasterInfo = isRasterInfo; -exports.isGeostatsLayer = isGeostatsLayer; -function isNumericGeostatsAttribute(attr) { - return attr.type === "number"; -} -function isLegacyGeostatsLayer(layer) { - if ("attributesCount" in layer && layer.attributesCount) { - return layer.attributes[0].countDistinct === undefined; - } - else { - return !("bounds" in layer); - } -} -function isLegacyGeostatsAttribute(attr) { - return Array.isArray(attr.values); -} -/** - * SuggestedRasterPresentation is a hint to the client on how to present the - * raster data. This can be used to determine the default visualization type for - * the raster data. - * - * - "categorical" is used for rasters with a color interpretation of "Palette", - * or which have a small number of unique values - * - "continuous" is used for rasters with a color interpretation of "Gray" - * - "rgb" is used for rasters which can be simply presented as an RGB image - */ -var SuggestedRasterPresentation; -(function (SuggestedRasterPresentation) { - SuggestedRasterPresentation[SuggestedRasterPresentation["categorical"] = 0] = "categorical"; - SuggestedRasterPresentation[SuggestedRasterPresentation["continuous"] = 1] = "continuous"; - SuggestedRasterPresentation[SuggestedRasterPresentation["rgb"] = 2] = "rgb"; -})(SuggestedRasterPresentation || (exports.SuggestedRasterPresentation = SuggestedRasterPresentation = {})); -function isRasterInfo(info) { - return info.bands !== undefined; -} -function isGeostatsLayer(data) { - return (!Array.isArray(data) && data.attributes !== undefined); -} diff --git a/packages/geostats-types/lib/index.ts b/packages/geostats-types/lib/index.ts index 50f226bc3..ec0a19d5a 100644 --- a/packages/geostats-types/lib/index.ts +++ b/packages/geostats-types/lib/index.ts @@ -96,7 +96,7 @@ export type LegacyGeostatsAttribute = Omit< }; export function isNumericGeostatsAttribute( - attr: GeostatsAttribute + attr: GeostatsAttribute, ): attr is NumericGeostatsAttribute { return attr.type === "number"; } @@ -156,7 +156,7 @@ export type LegacyGeostatsLayer = Omit< }; export function isLegacyGeostatsLayer( - layer: LegacyGeostatsLayer | GeostatsLayer + layer: LegacyGeostatsLayer | GeostatsLayer, ): layer is LegacyGeostatsLayer { if ("attributesCount" in layer && (layer as any).attributesCount) { return (layer as GeostatsLayer).attributes[0].countDistinct === undefined; @@ -166,7 +166,7 @@ export function isLegacyGeostatsLayer( } export function isLegacyGeostatsAttribute( - attr: LegacyGeostatsAttribute | GeostatsAttribute + attr: LegacyGeostatsAttribute | GeostatsAttribute, ): attr is LegacyGeostatsAttribute { return Array.isArray(attr.values); } @@ -255,15 +255,25 @@ export interface RasterInfo { } export function isRasterInfo( - info: RasterInfo | GeostatsLayer | any + info: RasterInfo | GeostatsLayer | any, ): info is RasterInfo { return (info as RasterInfo).bands !== undefined; } export function isGeostatsLayer( - data: RasterInfo | GeostatsLayer | any + data: RasterInfo | GeostatsLayer | any, ): data is GeostatsLayer { - return ( - !Array.isArray(data) && (data as GeostatsLayer).attributes !== undefined - ); + if (!data) { + return false; + } + if (Array.isArray(data)) { + return false; + } + if (typeof data !== "object") { + return false; + } + if (!("attributes" in data)) { + return false; + } + return true; } diff --git a/packages/geostats-types/package.json b/packages/geostats-types/package.json index df8c40c4e..1bc6622e2 100644 --- a/packages/geostats-types/package.json +++ b/packages/geostats-types/package.json @@ -5,14 +5,14 @@ "author": "Chad Burt ", "homepage": "https://github.com/seasketch/next#readme", "license": "BSD-3-Clause", - "main": "lib/index.js", + "main": "dist/lib/index.js", "directories": { - "lib": "lib", + "lib": "dist/lib", "test": "__tests__" }, - "types": "lib/index.d.ts", + "types": "dist/lib/index.d.ts", "files": [ - "lib" + "dist/lib" ], "repository": { "type": "git", diff --git a/packages/overlay-engine/dist/rasterStats.d.ts.map b/packages/overlay-engine/dist/rasterStats.d.ts.map index f26983e10..0d50be918 100644 --- a/packages/overlay-engine/dist/rasterStats.d.ts.map +++ b/packages/overlay-engine/dist/rasterStats.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"rasterStats.d.ts","sourceRoot":"","sources":["../src/rasterStats.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,MAAM,MAAM,cAAc,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE9C,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,cAAc,EAAE,EAC3B,UAAU,EAAE,MAAM,GACjB,cAAc,EAAE,CAqClB;AAWD,wBAAsB,oBAAoB,CACxC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,OAAO,GAAG,YAAY,CAAC,GACvC,OAAO,CAAC;IAAE,KAAK,EAAE,eAAe,EAAE,CAAA;CAAE,CAAC,CAyEvC"} \ No newline at end of file +{"version":3,"file":"rasterStats.d.ts","sourceRoot":"","sources":["../src/rasterStats.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAC/D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD,MAAM,MAAM,cAAc,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE9C,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,cAAc,EAAE,EAC3B,UAAU,EAAE,MAAM,GACjB,cAAc,EAAE,CAqClB;AAWD,wBAAsB,oBAAoB,CACxC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,OAAO,GAAG,YAAY,CAAC,GACvC,OAAO,CAAC;IAAE,KAAK,EAAE,eAAe,EAAE,CAAA;CAAE,CAAC,CAqGvC"} \ No newline at end of file diff --git a/packages/overlay-engine/dist/rasterStats.js b/packages/overlay-engine/dist/rasterStats.js index 8dddd2a9a..7dd8128d6 100644 --- a/packages/overlay-engine/dist/rasterStats.js +++ b/packages/overlay-engine/dist/rasterStats.js @@ -1,7 +1,11 @@ "use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", { value: true }); exports.downsampleHistogram = downsampleHistogram; exports.calculateRasterStats = calculateRasterStats; +const bbox_1 = __importDefault(require("@turf/bbox")); function downsampleHistogram(histogram, maxEntries) { if (histogram.length === 0 || histogram.length <= maxEntries) { return histogram; @@ -47,6 +51,29 @@ async function calculateRasterStats(sourceUrl, feature) { const geoblaze = getGeoblaze(); try { const raster = await geoblaze.parse(sourceUrl); + const featureBBox = (0, bbox_1.default)(feature, { recompute: true }); + const rasterBBox = [raster.xmin, raster.ymin, raster.xmax, raster.ymax]; + if (!intersects(featureBBox, rasterBBox)) { + console.log("No intersection between feature and raster", featureBBox, rasterBBox); + // Without this check we just get errors like this: + // Cannot read properties of undefined (reading 'vrm') + return { + bands: [ + { + count: 0, + min: NaN, + max: NaN, + mean: NaN, + median: NaN, + range: NaN, + histogram: [], + invalid: 0, + sum: 0, + }, + ], + }; + } + console.log("raster", raster); const stats = await geoblaze.stats(raster, feature, { stats: [ "count", @@ -109,4 +136,10 @@ async function calculateRasterStats(sourceUrl, feature) { } } } +function intersects(bbox1, bbox2) { + return (bbox1[0] <= bbox2[2] && + bbox1[2] >= bbox2[0] && + bbox1[1] <= bbox2[3] && + bbox1[3] >= bbox2[1]); +} //# sourceMappingURL=rasterStats.js.map \ No newline at end of file diff --git a/packages/overlay-engine/dist/rasterStats.js.map b/packages/overlay-engine/dist/rasterStats.js.map index 12ef3ed81..12dcc010f 100644 --- a/packages/overlay-engine/dist/rasterStats.js.map +++ b/packages/overlay-engine/dist/rasterStats.js.map @@ -1 +1 @@ -{"version":3,"file":"rasterStats.js","sourceRoot":"","sources":["../src/rasterStats.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"rasterStats.js","sourceRoot":"","sources":["../src/rasterStats.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/overlay-engine/src/rasterStats.ts b/packages/overlay-engine/src/rasterStats.ts index 6054ac020..d01ec0fea 100644 --- a/packages/overlay-engine/src/rasterStats.ts +++ b/packages/overlay-engine/src/rasterStats.ts @@ -1,10 +1,11 @@ -import { Feature, MultiPolygon, Polygon } from "geojson"; +import { BBox, Feature, MultiPolygon, Polygon } from "geojson"; import { RasterBandStats } from "./metrics/metrics"; +import calcBBox from "@turf/bbox"; export type HistogramEntry = [number, number]; export function downsampleHistogram( histogram: HistogramEntry[], - maxEntries: number + maxEntries: number, ): HistogramEntry[] { if (histogram.length === 0 || histogram.length <= maxEntries) { return histogram; @@ -55,11 +56,39 @@ function getGeoblaze() { export async function calculateRasterStats( sourceUrl: string, - feature: Feature + feature: Feature, ): Promise<{ bands: RasterBandStats[] }> { const geoblaze = getGeoblaze(); try { const raster = await geoblaze.parse(sourceUrl); + const featureBBox = calcBBox(feature, { recompute: true }); + const rasterBBox = [raster.xmin, raster.ymin, raster.xmax, raster.ymax]; + if (!intersects(featureBBox as BBox, rasterBBox as BBox)) { + console.log( + "No intersection between feature and raster", + featureBBox, + rasterBBox, + ); + + // Without this check we just get errors like this: + // Cannot read properties of undefined (reading 'vrm') + return { + bands: [ + { + count: 0, + min: NaN, + max: NaN, + mean: NaN, + median: NaN, + range: NaN, + histogram: [], + invalid: 0, + sum: 0, + }, + ], + }; + } + console.log("raster", raster); const stats = await geoblaze.stats( raster, feature, @@ -79,14 +108,14 @@ export async function calculateRasterStats( undefined, { vrm: "minimal", - } + }, ); return { bands: stats.map((stat: any) => { const rawHistogram: HistogramEntry[] = Array.isArray(stat.histogram) ? (stat.histogram as HistogramEntry[]) : Object.values( - stat.histogram as Record + stat.histogram as Record, ).map((r) => [r.n, r.ct] as HistogramEntry); const histogram = downsampleHistogram(rawHistogram, 200); @@ -130,3 +159,12 @@ export async function calculateRasterStats( } } } + +function intersects(bbox1: BBox, bbox2: BBox) { + return ( + bbox1[0] <= bbox2[2] && + bbox1[2] >= bbox2[0] && + bbox1[1] <= bbox2[3] && + bbox1[3] >= bbox2[1] + ); +} diff --git a/packages/overlay-worker/src/overlay-worker.ts b/packages/overlay-worker/src/overlay-worker.ts index 16ce5ae85..cd3c4b2db 100644 --- a/packages/overlay-worker/src/overlay-worker.ts +++ b/packages/overlay-worker/src/overlay-worker.ts @@ -44,6 +44,10 @@ import { guaranteeHelpers, } from "overlay-engine/src/utils/helpers"; import { reprojectFeatureTo6933 } from "overlay-engine/src/utils/reproject"; +import { ContainerIndex } from "overlay-engine/src/utils/containerIndex"; +import { evaluateCql2JSONQuery } from "overlay-engine/src/cql2"; +import { union } from "overlay-engine/src/utils/polygonClipping"; +import * as clipping from "polyclip-ts"; const SIMPLIFICATION_TOLERANCE = 0.000018; @@ -101,7 +105,7 @@ const sourceCache = new SourceCache("1GB", { throw new Error( `${e.message}. ${url} range=${range[0]}-${ range[1] ? range[1] : "" - }: ${e.message}` + }: ${e.message}`, ); }); // .finally(() => { @@ -115,7 +119,7 @@ const sourceCache = new SourceCache("1GB", { }); const workerPool = createClippingWorkerPool( - process.env.PISCINA_WORKER_PATH || "worker.js" + process.env.PISCINA_WORKER_PATH || "worker.js", ); export default async function handler(payload: OverlayWorkerPayload) { @@ -124,13 +128,13 @@ export default async function handler(payload: OverlayWorkerPayload) { const progressNotifier = new ProgressNotifier( payload.jobKey, 1000, - payload.queueUrl + payload.queueUrl, ); await sendBeginMessage( payload.jobKey, "/test", new Date().toISOString(), - payload.queueUrl + payload.queueUrl, ); const helpers = guaranteeHelpers({ progress: async (progress: number, message?: string) => { @@ -156,23 +160,23 @@ export default async function handler(payload: OverlayWorkerPayload) { const area = await calculateArea( payload.subject.clippingLayers, sourceCache, - helpers + helpers, ); await flushMessages(); await sendResultMessage( payload.jobKey, area, payload.queueUrl, - Date.now() - startTime + Date.now() - startTime, ); return; } else if (subjectIsFragment(payload.subject)) { throw new Error( - "Total area for fragments not implemented in worker." + "Total area for fragments not implemented in worker.", ); } else { throw new Error( - "Unknown subject type. Must be geography or fragment." + "Unknown subject type. Must be geography or fragment.", ); } } @@ -183,17 +187,17 @@ export default async function handler(payload: OverlayWorkerPayload) { const { intersectionFeature, differenceSources } = await subjectsForAnalysis( payload.subject as MetricSubjectFragment | MetricSubjectGeography, - helpers + helpers, ); const source = await sourceCache.get>( payload.sourceUrl, { pageSize: "5MB", - } + }, ); const bufferedIntersectionFeature = applySubjectBuffer( intersectionFeature, - payload.bufferDistanceKm + payload.bufferDistanceKm, ); const processor = new OverlayEngineBatchProcessor( @@ -218,7 +222,7 @@ export default async function handler(payload: OverlayWorkerPayload) { payload.jobKey, area, payload.queueUrl, - Date.now() - startTime + Date.now() - startTime, ); return; } @@ -232,13 +236,13 @@ export default async function handler(payload: OverlayWorkerPayload) { const { intersectionFeature, differenceSources } = await subjectsForAnalysis( payload.subject as MetricSubjectFragment | MetricSubjectGeography, - helpers + helpers, ); const source = await sourceCache.get>( payload.sourceUrl, { pageSize: "5MB", - } + }, ); // Extract valueColumn from parameters for column_values const columnValuesProperty = @@ -246,7 +250,7 @@ export default async function handler(payload: OverlayWorkerPayload) { const bufferedIntersectionFeature = applySubjectBuffer( intersectionFeature, - payload.bufferDistanceKm + payload.bufferDistanceKm, ); const processor = new OverlayEngineBatchProcessor( @@ -262,7 +266,7 @@ export default async function handler(payload: OverlayWorkerPayload) { workerPool, payload.includedColumns, payload.maxResults, - columnValuesProperty + columnValuesProperty, ); const result = await processor.calculate(); await flushMessages(); @@ -270,7 +274,7 @@ export default async function handler(payload: OverlayWorkerPayload) { payload.jobKey, result, payload.queueUrl, - Date.now() - startTime + Date.now() - startTime, ); return; } @@ -283,19 +287,33 @@ export default async function handler(payload: OverlayWorkerPayload) { } if (payload.epsg !== 6933) { throw new Error( - `Support for projection EPSG:${payload.epsg} not implemented in worker.` + `Support for projection EPSG:${payload.epsg} not implemented in worker.`, ); } - const { intersectionFeature, differenceSources } = + let { intersectionFeature, differenceSources } = await subjectsForAnalysis( payload.subject as MetricSubjectFragment | MetricSubjectGeography, - helpers + helpers, ); - // if (subjectIsGeography(payload.subject)) { - // throw new Error( - // `raster_stats for geographies not implemented in worker yet.` - // ); - // } else { + const originalLength = JSON.stringify( + intersectionFeature, + null, + 2, + ).length; + if (subjectIsGeography(payload.subject)) { + // attempt to build complete multipolygon representing the geography + // by subtracting difference source features from the intersection + // feature. + intersectionFeature = await buildCompleteGeographyMultiPolygon( + intersectionFeature, + differenceSources, + ); + console.log( + "built complete geography multipolygon", + originalLength, + JSON.stringify(intersectionFeature, null, 2).length, + ); + } const f = reprojectFeatureTo6933(intersectionFeature); const result = await calculateRasterStats(payload.sourceUrl, f); await flushMessages(); @@ -303,7 +321,7 @@ export default async function handler(payload: OverlayWorkerPayload) { payload.jobKey, result, payload.queueUrl, - Date.now() - startTime + Date.now() - startTime, ); return; // } @@ -319,24 +337,24 @@ export default async function handler(payload: OverlayWorkerPayload) { const { intersectionFeature, differenceSources } = await subjectsForAnalysis( payload.subject as MetricSubjectFragment | MetricSubjectGeography, - helpers + helpers, ); const source = await sourceCache.get>( payload.sourceUrl, { pageSize: "5MB", - } + }, ); const result = await calculateDistanceToShore( intersectionFeature, - source + source, ); await flushMessages(); await sendResultMessage( payload.jobKey, result, payload.queueUrl, - Date.now() - startTime + Date.now() - startTime, ); return; } @@ -351,9 +369,9 @@ export default async function handler(payload: OverlayWorkerPayload) { e instanceof Error ? e.message : typeof e === "string" - ? e - : "Unknown error", - payload.queueUrl + ? e + : "Unknown error", + payload.queueUrl, ); // throw e; } finally { @@ -390,7 +408,7 @@ export function validatePayload(data: any): OverlayWorkerPayload { typeof data.subject.id !== "number" ) { throw new Error( - 'Geography subject must have type "geography" and numeric id' + 'Geography subject must have type "geography" and numeric id', ); } } else { @@ -403,12 +421,12 @@ export function validatePayload(data: any): OverlayWorkerPayload { if (data.type !== "total_area") { if (!data.sourceUrl || typeof data.sourceUrl !== "string") { throw new Error( - `Payload type "${data.type}" must have sourceUrl property` + `Payload type "${data.type}" must have sourceUrl property`, ); } if (!data.sourceType || typeof data.sourceType !== "string") { throw new Error( - `Payload type "${data.type}" must have sourceType property` + `Payload type "${data.type}" must have sourceType property`, ); } if (data.groupBy && typeof data.groupBy !== "string") { @@ -429,14 +447,14 @@ export function validatePayload(data: any): OverlayWorkerPayload { // Type guard for enhanced fragment subjects export function subjectIsFragment( - subject: any + subject: any, ): subject is MetricSubjectFragment & FragmentSubjectPayload { return "hash" in subject && "fragmentHash" in subject; } // Type guard for enhanced geography subjects export function subjectIsGeography( - subject: any + subject: any, ): subject is MetricSubjectGeography & GeographySubjectPayload { return ( "type" in subject && @@ -446,7 +464,7 @@ export function subjectIsGeography( } function polygonFromFragment( - subject: FragmentSubjectPayload + subject: FragmentSubjectPayload, ): Feature { if (!subject.geobuf) { throw new Error("geobuf is required for fragment subjects"); @@ -467,7 +485,7 @@ function polygonFromFragment( async function subjectsForAnalysis( subject: MetricSubjectFragment | MetricSubjectGeography, - helpers: GuaranteedOverlayWorkerHelpers + helpers: GuaranteedOverlayWorkerHelpers, ): Promise<{ intersectionFeature: Feature; differenceSources: { @@ -484,7 +502,7 @@ async function subjectsForAnalysis( helpers, { pageSize: "5MB", - } + }, ); return { intersectionFeature, @@ -492,7 +510,7 @@ async function subjectsForAnalysis( }; } else if ("geobuf" in subject) { const feature = polygonFromFragment( - subject as unknown as FragmentSubjectPayload + subject as unknown as FragmentSubjectPayload, ); return { intersectionFeature: feature, @@ -505,7 +523,7 @@ async function subjectsForAnalysis( function applySubjectBuffer( feature: Feature, - bufferDistanceKm?: number + bufferDistanceKm?: number, ): Feature { if ( typeof bufferDistanceKm !== "number" || @@ -529,3 +547,99 @@ function applySubjectBuffer( } return feature; } + +/** + * Builds a complete MultiPolygon representing the true geography area by + * subtracting all difference-source features from the intersection feature. + * + * Used for raster_stats analysis where the actual geometry (not just an area + * value) is needed to spatially query a raster dataset. + * + * Strategy for efficiency: + * 1. Compute one bounding envelope from the intersection feature for spatial search. + * 2. Build a ContainerIndex on a simplified copy of the intersection feature so + * features that fall entirely outside can be skipped cheaply. + * 3. Stream candidate features from each differenceSource, apply any CQL2 filter, + * skip "outside" candidates, and collect the remainder. + * 4. Union all collected difference geometries into a single geometry, then + * apply one clipping.difference call — avoiding repeated incremental clips. + */ +async function buildCompleteGeographyMultiPolygon( + intersectionFeature: Feature, + differenceSources: { + layerId: string; + source: FlatGeobufSource>; + cql2Query?: Cql2Query | undefined; + }[], +): Promise> { + if (differenceSources.length === 0) { + return intersectionFeature; + } + + // Compute a single bounding envelope from the geometry coordinates directly. + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + const polys = + intersectionFeature.geometry.type === "Polygon" + ? [intersectionFeature.geometry.coordinates] + : intersectionFeature.geometry.coordinates; + for (const poly of polys) { + for (const ring of poly) { + for (const [x, y] of ring) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + } + } + const envelope = { minX, minY, maxX, maxY }; + + // Simplified intersection feature for ContainerIndex — fast classify without + // touching the high-resolution source geometry. + const simplified = simplify( + intersectionFeature as Feature, + { tolerance: 0.002 }, + ) as Feature; + const containerIndex = new ContainerIndex(simplified); + + // Collect difference polygon coordinates, skipping features with no overlap. + const differenceGeoms: clipping.Geom[] = []; + for (const { source, cql2Query } of differenceSources) { + for await (const f of source.getFeaturesAsync([envelope])) { + if (cql2Query && !evaluateCql2JSONQuery(cql2Query, f.properties)) { + continue; + } + if (containerIndex.classify(f) === "outside") { + continue; + } + differenceGeoms.push(f.geometry.coordinates as clipping.Geom); + } + } + + if (differenceGeoms.length === 0) { + return intersectionFeature; + } + + // Union all difference geometries first, then apply a single difference + // operation. This is faster than iterative clipping. + const unionedDifference: clipping.Geom = + differenceGeoms.length === 1 ? differenceGeoms[0] : union(differenceGeoms); + + const result = clipping.difference( + intersectionFeature.geometry.coordinates as clipping.Geom, + unionedDifference, + ); + + if (result.length === 0) { + return intersectionFeature; + } + + return { + type: "Feature", + geometry: { type: "MultiPolygon", coordinates: result }, + properties: intersectionFeature.properties, + }; +}