From 25ac23726feb4c09fc05e1b4730d28fa2717fa56 Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Wed, 11 Mar 2026 16:41:48 -0700 Subject: [PATCH 01/11] WIP --- packages/client/src/index.css | 16 + .../src/reports/hooks/useNumberFormatters.ts | 6 +- .../widgets/ClassRowSettingsPopover.tsx | 5 + .../src/reports/widgets/FeatureCountTable.tsx | 10 +- .../src/reports/widgets/InlineMetric.tsx | 94 ++++- .../reports/widgets/OverlappingAreasTable.tsx | 182 ++++++++- .../reports/widgets/RasterProportionTable.tsx | 347 ++++++++++++++++++ .../client/src/reports/widgets/widgets.tsx | 93 ++++- .../geostats-types/dist/lib/index.d.ts.map | 2 +- packages/geostats-types/dist/lib/index.js | 14 +- packages/geostats-types/dist/lib/index.js.map | 2 +- packages/geostats-types/lib/index.d.ts | 208 ----------- packages/geostats-types/lib/index.js | 44 --- packages/geostats-types/lib/index.ts | 26 +- packages/geostats-types/package.json | 8 +- packages/overlay-worker/src/overlay-worker.ts | 200 +++++++--- 16 files changed, 929 insertions(+), 328 deletions(-) create mode 100644 packages/client/src/reports/widgets/RasterProportionTable.tsx delete mode 100644 packages/geostats-types/lib/index.d.ts delete mode 100644 packages/geostats-types/lib/index.js 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/hooks/useNumberFormatters.ts b/packages/client/src/reports/hooks/useNumberFormatters.ts index 4b34c25dc..4f6dec390 100644 --- a/packages/client/src/reports/hooks/useNumberFormatters.ts +++ b/packages/client/src/reports/hooks/useNumberFormatters.ts @@ -197,9 +197,9 @@ 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}%`) ); diff --git a/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx b/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx index 4facca0bb..3cd062183 100644 --- a/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx +++ b/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx @@ -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(); @@ -454,6 +457,7 @@ export const ClassRowSettingsPopover = ({

*/}
+ {!hideGroupBy && (

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

+ )} {metricType === "overlay_area" && group.source?.stableId && (
diff --git a/packages/client/src/reports/widgets/FeatureCountTable.tsx b/packages/client/src/reports/widgets/FeatureCountTable.tsx index b7f46a41d..4c4f1a1a7 100644 --- a/packages/client/src/reports/widgets/FeatureCountTable.tsx +++ b/packages/client/src/reports/widgets/FeatureCountTable.tsx @@ -125,7 +125,11 @@ export function getClassTableRows(options: { const key = classTableRowKey(dependency.stableId!, "*"); rows.push({ key, - label: options.customLabels?.[key] || options.allFeaturesLabel, + label: + options.customLabels?.[key] || + (multiSource + ? source?.tableOfContentsItem?.title || options.allFeaturesLabel + : options.allFeaturesLabel), groupByKey: "*", sourceId: dependency.stableId!.toString(), stableId: options.stableIds?.[key], @@ -238,7 +242,9 @@ export function combineMetricsBySource( fragments: combineMetricsForFragments( metrics.filter( (m) => - m.sourceUrl === source.sourceUrl && subjectIsFragment(m.subject) + m.sourceUrl === source.sourceUrl && + subjectIsFragment(m.subject) && + m.subject.geographies.includes(geographyId) ) as Pick[] ) as T, geographies: metrics.find( diff --git a/packages/client/src/reports/widgets/InlineMetric.tsx b/packages/client/src/reports/widgets/InlineMetric.tsx index 710c11278..50ece2fec 100644 --- a/packages/client/src/reports/widgets/InlineMetric.tsx +++ b/packages/client/src/reports/widgets/InlineMetric.tsx @@ -309,7 +309,9 @@ export type InlineMetricComponentSettings = { | "geography_overlay_area" | "count" | "column_values" - | "raster_stats"; + | "raster_stats" + | "geography_raster_stats" + | "geography_proportion_captured"; stat?: ColumnValuesStatKey; rasterStat?: RasterValuesStatKey; hideLabelForCount?: boolean; @@ -547,6 +549,76 @@ const _InlineMetric: ReportWidget = ({ 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 +394,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 ( @@ -279,6 +437,18 @@ export const OverlappingAreasTable: ReportWidget<
{showPercentColumn && (
+ {typeof percent === "number" && + percent > 1.05 && + primaryGeographyId !== undefined && ( + + )} {loading ? ( ) : typeof percent === "number" ? ( @@ -294,7 +464,9 @@ export const OverlappingAreasTable: ReportWidget< row.color)} + includeColorColumn={ + showColorSwatches && rows.some((row) => row.color) + } 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..6dbec558d --- /dev/null +++ b/packages/client/src/reports/widgets/RasterProportionTable.tsx @@ -0,0 +1,347 @@ +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 "./FeatureCountTable"; +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.color && ( +
+ +
+ )} +
+ + {row.label} + +
+
+ {loading ? ( + + ) : ( + formatters.percent(percent) + )} +
+
+ ); + })} + row.color)} + /> +
+ {!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/widgets.tsx b/packages/client/src/reports/widgets/widgets.tsx index 88f59647b..1b5d06b18 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-stats-table.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-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, + }; +} From 03d93d96f683ee83b8e63bc75d36f17b7888940e Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 09:32:19 -0700 Subject: [PATCH 02/11] Add support for downloading all fragments related to a calculation as a GeoJSON FeatureCollection --- packages/api/src/server.ts | 45 +++++++++ .../reports/ReportMetricsProgressDetails.tsx | 93 +++++++------------ 2 files changed, 80 insertions(+), 58 deletions(-) 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/client/src/reports/ReportMetricsProgressDetails.tsx b/packages/client/src/reports/ReportMetricsProgressDetails.tsx index afb0ab9f1..a6fe50b4b 100644 --- a/packages/client/src/reports/ReportMetricsProgressDetails.tsx +++ b/packages/client/src/reports/ReportMetricsProgressDetails.tsx @@ -10,7 +10,6 @@ import { 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"; @@ -216,6 +215,31 @@ export default function ReportMetricsProgressDetails({ 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 - )} - + + {t("Polygon ")} + + {(metric.subject as { hash: string }).hash.substring( + 0, + 36 + )} - {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 - ); - } - }} - > - - - - - - {t("Download GeoJSON")} - - - - - )} } state={metric.state} From bf35aa11522718749d9c044e363628d468868794 Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 10:41:00 -0700 Subject: [PATCH 03/11] Organize ReportMetricsProgressDetails better --- .../reports/ReportMetricsProgressDetails.tsx | 363 +++++++++++++----- 1 file changed, 271 insertions(+), 92 deletions(-) diff --git a/packages/client/src/reports/ReportMetricsProgressDetails.tsx b/packages/client/src/reports/ReportMetricsProgressDetails.tsx index a6fe50b4b..047d8feda 100644 --- a/packages/client/src/reports/ReportMetricsProgressDetails.tsx +++ b/packages/client/src/reports/ReportMetricsProgressDetails.tsx @@ -1,6 +1,7 @@ import { Trans, useTranslation } from "react-i18next"; import { useCallback, useContext, useMemo } from "react"; import { + CompatibleSpatialMetricDetailsFragment, DraftReportDependenciesDocument, Geography, ReportDependenciesDocument, @@ -83,6 +84,34 @@ 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] + ); + return (
    @@ -171,38 +200,60 @@ export default function ReportMetricsProgressDetails({ recalculated if a source layer is updated.

    -
      - {state.geographyMetrics.map((metric) => ( - - layer.output || - layer.sourceProcessingJob?.state === - SpatialMetricState.Complete - )} - /> +
      + {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 && ( @@ -214,71 +265,97 @@ export default function ReportMetricsProgressDetails({ Polygons may be split in order to account for antimeridian crossings or overlap with other sketches in a collection. + {isAuthenticated && ( + <> + {" "} + + + )}

- {isAuthenticated && ( -

- -

- )} - + )} @@ -298,3 +375,105 @@ function nameForGeography( } return geographies.find((g) => g.id === subject.id)?.name; } + +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; + } +} From 2efd5cf10bfeabf3eca8af5c731debd3f3fdb775 Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 10:57:56 -0700 Subject: [PATCH 04/11] Add layer authorship info --- .../reports/ReportMetricsProgressDetails.tsx | 247 ++++++++++++++---- 1 file changed, 194 insertions(+), 53 deletions(-) diff --git a/packages/client/src/reports/ReportMetricsProgressDetails.tsx b/packages/client/src/reports/ReportMetricsProgressDetails.tsx index 047d8feda..3fff764f3 100644 --- a/packages/client/src/reports/ReportMetricsProgressDetails.tsx +++ b/packages/client/src/reports/ReportMetricsProgressDetails.tsx @@ -2,11 +2,13 @@ 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"; @@ -17,6 +19,10 @@ 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, @@ -112,6 +118,34 @@ export default function ReportMetricsProgressDetails({ [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 (
@@ -127,64 +161,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} - /> +
    • + + + {layerTitle} + + {attribution && ( + + {attribution.sourceTypeLabel} + + )} + {attribution?.profile && ( + + + + )} + + + + handleRepairSource( + layer.sourceProcessingJob!.jobKey + ) + : undefined + } + repairLoading={recalculateState.loading} + /> + +
    • ); })}
    @@ -376,6 +449,74 @@ 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("Authored by ")} + {profile.fullname} +
    + )} + {profile.email && ( +
    {profile.email}
    + )} + {profile.affiliations && ( +
    + {profile.affiliations} +
    + )} +
    + +
    +
    +
    + ); +} + function groupMetricsBySourceAndOperation( metrics: CompatibleSpatialMetricDetailsFragment[], overlaySources: Array<{ From a5bf87339536c0cd9c7acdb56f706f4a57cf4586 Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 12:19:20 -0700 Subject: [PATCH 05/11] Better formatting of overlay sources in calculations modal --- .../reports/ReportMetricsProgressDetails.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/client/src/reports/ReportMetricsProgressDetails.tsx b/packages/client/src/reports/ReportMetricsProgressDetails.tsx index 3fff764f3..d13ce9552 100644 --- a/packages/client/src/reports/ReportMetricsProgressDetails.tsx +++ b/packages/client/src/reports/ReportMetricsProgressDetails.tsx @@ -181,6 +181,13 @@ export default function ReportMetricsProgressDetails({ className="rounded-md border border-gray-100 bg-gray-50/50 px-2 py-1.5 flex items-center gap-2 min-w-0" > + {attribution?.profile && ( + + + + )} )} - {attribution?.profile && ( - - - - )} {profile.fullname && (
    - {t("Authored by ")} + {t("Uploaded by ")} {profile.fullname}
    )} From 4bb7870ad5c3defd1e29b84b20c3448da3acbe70 Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 12:19:43 -0700 Subject: [PATCH 06/11] Better formatting of overlay sources in calculations modal --- .../reports/components/ReportTaskLineItem.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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}
  • ); } From a485af9125707397a1f3a48a7d1f892118229bb8 Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 12:20:04 -0700 Subject: [PATCH 07/11] Ensure appropriate overlap setting is added to metric dependencies --- .../reports/widgets/ClassRowSettingsPopover.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx b/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx index 3cd062183..450245f63 100644 --- a/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx +++ b/packages/client/src/reports/widgets/ClassRowSettingsPopover.tsx @@ -187,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, }); } From 905912858265a1e19b989f93a368a7f15755aedb Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 12:20:46 -0700 Subject: [PATCH 08/11] Display values > 100% in case they represent real errors --- packages/client/src/reports/hooks/useNumberFormatters.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/client/src/reports/hooks/useNumberFormatters.ts b/packages/client/src/reports/hooks/useNumberFormatters.ts index 4f6dec390..a945fc4e3 100644 --- a/packages/client/src/reports/hooks/useNumberFormatters.ts +++ b/packages/client/src/reports/hooks/useNumberFormatters.ts @@ -204,6 +204,8 @@ export function useNumberFormatters({ 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; From 1ea9bc9977d1ec6dfdc2b21000cd292e2d532f34 Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 12:21:05 -0700 Subject: [PATCH 09/11] Add optional className prop to ProfilePhoto component for better styling flexibility --- packages/client/src/admin/users/ProfilePhoto.tsx | 3 +++ 1 file changed, 3 insertions(+) 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" : "" )} From 46f8ad14641103952b813d2b8e184786f64079d4 Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 15:25:38 -0700 Subject: [PATCH 10/11] Fix for some accuracy issues, color rendering in RasterProportionTable --- packages/api/src/sketches.ts | 3 + .../reports/ReportMetricsProgressDetails.tsx | 46 +++ .../widgets/ClassRowSettingsPopover.tsx | 2 +- .../src/reports/widgets/ClassTableRows.ts | 329 ++++++++++++++++++ .../src/reports/widgets/FeatureCountTable.tsx | 250 +------------ .../reports/widgets/FeaturePresenceTable.tsx | 19 +- .../reports/widgets/OverlappingAreasTable.tsx | 18 +- .../reports/widgets/RasterProportionTable.tsx | 35 +- .../widgets/SwatchForClassTableRow.tsx | 75 ++++ .../overlay-engine/dist/rasterStats.d.ts.map | 2 +- packages/overlay-engine/dist/rasterStats.js | 33 ++ .../overlay-engine/dist/rasterStats.js.map | 2 +- packages/overlay-engine/src/rasterStats.ts | 48 ++- 13 files changed, 569 insertions(+), 293 deletions(-) create mode 100644 packages/client/src/reports/widgets/ClassTableRows.ts create mode 100644 packages/client/src/reports/widgets/SwatchForClassTableRow.tsx 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/src/reports/ReportMetricsProgressDetails.tsx b/packages/client/src/reports/ReportMetricsProgressDetails.tsx index d13ce9552..eaa0992c5 100644 --- a/packages/client/src/reports/ReportMetricsProgressDetails.tsx +++ b/packages/client/src/reports/ReportMetricsProgressDetails.tsx @@ -118,6 +118,38 @@ export default function ReportMetricsProgressDetails({ [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", @@ -273,6 +305,13 @@ export default function ReportMetricsProgressDetails({ recalculated if a source layer is updated.

    + {geographyDurationSummary && ( +

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

    + )}
    {groupedGeographyMetrics.map((group) => (
    + {fragmentDurationSummary && ( +

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

    + )}
    {groupedFragmentMetrics.map((group) => (
    = 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 4c4f1a1a7..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,222 +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] || - (multiSource - ? source?.tableOfContentsItem?.title || options.allFeaturesLabel - : 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) && - 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; -} - type FeatureCountTableSettings = { showZeroCountCategories?: boolean; sortBy?: "count" | "name"; @@ -388,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( @@ -439,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] || @@ -469,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 && }
    )} - {showColorSwatches && row.color && ( -
    - -
    - )} + {showColorSwatches && }
    {row.label} @@ -465,7 +461,7 @@ export const OverlappingAreasTable: ReportWidget< count={paddingRowsCount} includeVisibilityColumn={hasVisibilityColumn} includeColorColumn={ - showColorSwatches && rows.some((row) => row.color) + showColorSwatches && rows.some(classTableRowHasSwatch) } showPercentColumn={showPercentColumn} /> diff --git a/packages/client/src/reports/widgets/RasterProportionTable.tsx b/packages/client/src/reports/widgets/RasterProportionTable.tsx index 6dbec558d..76faf7b54 100644 --- a/packages/client/src/reports/widgets/RasterProportionTable.tsx +++ b/packages/client/src/reports/widgets/RasterProportionTable.tsx @@ -1,10 +1,7 @@ import { useMemo } from "react"; import { Trans, useTranslation } from "react-i18next"; import { MetricDependency, RasterStats } from "overlay-engine"; -import { - ReportWidget, - TableHeadingsEditor, -} from "./widgets"; +import { ReportWidget, TableHeadingsEditor } from "./widgets"; import { ReportWidgetTooltipControls, TooltipMorePopover, @@ -24,7 +21,11 @@ import { ClassTableRowComponentSettings, combineMetricsBySource, getClassTableRows, -} from "./FeatureCountTable"; +} from "./ClassTableRows"; +import { + classTableRowHasSwatch, + SwatchForClassTableRow, +} from "./SwatchForClassTableRow"; import { ClassRowSettingsPopover } from "./ClassRowSettingsPopover"; import { LabeledDropdown } from "./LabeledDropdown"; import ReportLayerVisibilityCheckbox from "../components/ReportLayerVisibilityCheckbox"; @@ -202,26 +203,14 @@ export const RasterProportionTable: ReportWidget< ) : null}
    )} - {showColorSwatches && row.color && ( -
    - -
    - )} + {showColorSwatches && }
    {row.label}
    - {loading ? ( - - ) : ( - formatters.percent(percent) - )} + {loading ? : formatters.percent(percent)}
    ); @@ -229,7 +218,9 @@ export const RasterProportionTable: ReportWidget< row.color)} + includeColorColumn={ + showColorSwatches && rows.some(classTableRowHasSwatch) + } />
    {!loading && rows.length === 0 && ( @@ -309,9 +300,7 @@ export const RasterProportionTableTooltipControls: ReportWidgetTooltipControls = label={t("Sort by")} value={sortBy} options={sortOptions} - onChange={(val) => - handleUpdate({ sortBy: val as "value" | "name" }) - } + onChange={(val) => handleUpdate({ sortBy: val as "value" | "name" })} /> 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/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] + ); +} From aee4537fd1dda49f13b1c02a202db9d970c291b3 Mon Sep 17 00:00:00 2001 From: Chad Burt Date: Thu, 12 Mar 2026 15:47:20 -0700 Subject: [PATCH 11/11] Add appropriate screenshot for RasterProportionTable --- .../public/slashCommands/raster-proportion.png | Bin 0 -> 42985 bytes packages/client/src/reports/widgets/widgets.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 packages/client/public/slashCommands/raster-proportion.png diff --git a/packages/client/public/slashCommands/raster-proportion.png b/packages/client/public/slashCommands/raster-proportion.png new file mode 100644 index 0000000000000000000000000000000000000000..c97ac2e551f9332f5ff8eaafb516992d34084469 GIT binary patch literal 42985 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
    -`l*b(xmvJZcjgxa4wY{QIO~ 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 AF8}}l literal 0 HcmV?d00001 diff --git a/packages/client/src/reports/widgets/widgets.tsx b/packages/client/src/reports/widgets/widgets.tsx index 1b5d06b18..43a0f9144 100644 --- a/packages/client/src/reports/widgets/widgets.tsx +++ b/packages/client/src/reports/widgets/widgets.tsx @@ -1163,7 +1163,7 @@ export function buildReportCommandGroups({ 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-stats-table.png", + screenshotSrc: "/slashCommands/raster-proportion.png", run: (state, dispatch, view) => { return insertBlockMetric(view, state.selection.ranges[0], { type: "RasterProportionTable",