+ {superusersOnly && (
+
+ superusers only
+
+ )}
;
case SketchGeometryType.Collection:
return
;
+ case SketchGeometryType.FilteredPlanningUnits:
+ return
;
default:
return "";
}
diff --git a/packages/client/src/admin/surveys/AddFormElementButton.tsx b/packages/client/src/admin/surveys/AddFormElementButton.tsx
index ba84090d..e09eceb4 100644
--- a/packages/client/src/admin/surveys/AddFormElementButton.tsx
+++ b/packages/client/src/admin/surveys/AddFormElementButton.tsx
@@ -103,6 +103,12 @@ export default function AddFormElementButton({
return false;
}
}
+ if (
+ !formIsSketchClass &&
+ type?.componentName === "CollapsibleGroup"
+ ) {
+ return false;
+ }
return true;
})
.map(([id, C]) => (
diff --git a/packages/client/src/components/Modal.tsx b/packages/client/src/components/Modal.tsx
index 8d7ac3ee..44d07dae 100644
--- a/packages/client/src/components/Modal.tsx
+++ b/packages/client/src/components/Modal.tsx
@@ -73,7 +73,7 @@ export default function Modal(props: ModalProps) {
open={props.open === undefined ? true : props.open}
as={motion.div}
className={`relative ${props.tipyTop ? "z-50" : "z-30"}`}
- onClose={() => {
+ onClose={(e) => {
if (!props.disableBackdropClick) {
props.onRequestClose();
}
diff --git a/packages/client/src/components/SketchGeometryTypeSelector.tsx b/packages/client/src/components/SketchGeometryTypeSelector.tsx
index d6e3c345..b693f1f7 100644
--- a/packages/client/src/components/SketchGeometryTypeSelector.tsx
+++ b/packages/client/src/components/SketchGeometryTypeSelector.tsx
@@ -23,6 +23,7 @@ export default function SketchGeometryTypeSelector({
[SketchGeometryType.Polygon]: "Polygon",
[SketchGeometryType.Collection]: "Collection",
[SketchGeometryType.ChooseFeature]: "Choose feature",
+ [SketchGeometryType.FilteredPlanningUnits]: "Filtered planning units",
};
if (simpleFeatures) {
options = options.slice(0, -1);
diff --git a/packages/client/src/components/Switch.tsx b/packages/client/src/components/Switch.tsx
index 29782d73..3f15fc23 100644
--- a/packages/client/src/components/Switch.tsx
+++ b/packages/client/src/components/Switch.tsx
@@ -20,6 +20,12 @@ export default function Switch(props: SwitchProps) {
if (props.onClick && !props.disabled) {
props.onClick(!props.isToggled, e);
}
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onDoubleClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
}}
onKeyDown={(e) => {
if (
diff --git a/packages/client/src/dataLayers/MapContextManager.ts b/packages/client/src/dataLayers/MapContextManager.ts
index ec783008..c628b92d 100644
--- a/packages/client/src/dataLayers/MapContextManager.ts
+++ b/packages/client/src/dataLayers/MapContextManager.ts
@@ -13,6 +13,8 @@ import mapboxgl, {
Sources,
GeoJSONSource,
Expression,
+ VectorSource,
+ LineLayer,
} from "mapbox-gl";
import {
createContext,
@@ -161,6 +163,7 @@ export interface LayerState {
export interface SketchLayerState extends LayerState {
sketchClassId?: number;
+ filterMvtUrl?: string;
}
class MapContextManager extends EventEmitter {
map?: Map;
@@ -1089,7 +1092,12 @@ class MapContextManager extends EventEmitter {
* @param sketches
*/
setVisibleSketches(
- sketches: { id: number; timestamp?: string; sketchClassId?: number }[]
+ sketches: {
+ id: number;
+ timestamp?: string;
+ sketchClassId?: number;
+ filterMvtUrl?: string;
+ }[]
) {
const sketchIds = sketches.map(({ id }) => id);
// remove missing ids from internal state
@@ -1105,6 +1113,12 @@ class MapContextManager extends EventEmitter {
loading: true,
visible: true,
sketchClassId: sketch.sketchClassId,
+ filterMvtUrl: sketch.filterMvtUrl,
+ };
+ } else {
+ this.internalState.sketchLayerStates[sketch.id] = {
+ ...this.internalState.sketchLayerStates[sketch.id],
+ filterMvtUrl: sketch.filterMvtUrl,
};
}
}
@@ -1166,6 +1180,12 @@ class MapContextManager extends EventEmitter {
this.debouncedUpdateStyle();
}
+ unhideEditableSketch() {
+ delete this.hideEditableSketchId;
+ // request a redraw
+ this.debouncedUpdateStyle();
+ }
+
clearSketchEditingState() {
delete this.hideEditableSketchId;
this.unmarkSketchAsEditable();
@@ -1373,6 +1393,35 @@ class MapContextManager extends EventEmitter {
}
}
+ // Do the same for filter-layer- layers related to FilterLayerManager
+ let filterLayers: AnyLayer[] = [];
+ let filterSources: { [id: string]: VectorSource } = {};
+ if (existingStyle) {
+ filterLayers =
+ existingStyle.layers?.filter(
+ (l) => l.id.indexOf("filter-layer-") === 0
+ ) || [];
+ // @ts-ignore
+ const relatedSourceIds = filterLayers.map((l) => l.source || "");
+ for (const key in existingStyle.sources) {
+ if (relatedSourceIds.indexOf(key) > -1) {
+ filterSources[key] = existingStyle.sources[key] as VectorSource;
+ }
+ }
+ // look for all-cells layer
+ const allCellsLayer = existingStyle.layers?.find(
+ (l) => l.id === "all-cells"
+ );
+ if (allCellsLayer) {
+ filterLayers.push(allCellsLayer);
+ }
+ // look for all-cells source
+ const allCellsSource = existingStyle.sources["all-cells"];
+ if (allCellsSource) {
+ filterSources["all-cells"] = allCellsSource as VectorSource;
+ }
+ }
+
const { baseStyle, labelsLayerIndex, basemap } =
await this.getComputedBaseStyle();
@@ -1680,6 +1729,7 @@ class MapContextManager extends EventEmitter {
...baseStyle.sources,
...this.dynamicDataSources,
...glDrawSources,
+ ...filterSources,
};
baseStyle.layers = [
@@ -1687,6 +1737,7 @@ class MapContextManager extends EventEmitter {
...overLabels,
...this.dynamicLayers,
...glDrawLayers,
+ ...filterLayers,
];
// Evaluate any basemap optional layers
@@ -1727,22 +1778,35 @@ class MapContextManager extends EventEmitter {
if (id !== this.hideEditableSketchId) {
const timestamp = this.sketchTimestamps.get(id);
const cache = LocalSketchGeometryCache.get(id);
- sources[`sketch-${id}`] = {
- type: "geojson",
- data:
- cache && cache.timestamp === timestamp
- ? cache.feature
- : sketchGeoJSONUrl(id, timestamp),
- };
const layers = this.getLayersForSketch(
id,
id === this.editableSketchId,
sketchClassId
);
- if (this.editableSketchId && id !== this.editableSketchId) {
- reduceOpacity(layers);
+ if (
+ layers.length > 0 &&
+ "metadata" in layers[0] &&
+ this.internalState.sketchLayerStates[id].filterMvtUrl
+ ) {
+ sources[`sketch-${id}`] = {
+ type: "vector",
+ tiles: [this.internalState.sketchLayerStates[id].filterMvtUrl],
+ maxzoom: 14,
+ };
+ allLayers.push(...layers);
+ } else {
+ sources[`sketch-${id}`] = {
+ type: "geojson",
+ data:
+ cache && cache.timestamp === timestamp
+ ? cache.feature
+ : sketchGeoJSONUrl(id, timestamp),
+ };
+ if (this.editableSketchId && id !== this.editableSketchId) {
+ reduceOpacity(layers);
+ }
+ allLayers.push(...layers);
}
- allLayers.push(...layers);
}
}
return { layers: allLayers, sources };
@@ -1830,38 +1894,63 @@ class MapContextManager extends EventEmitter {
(this.selectedSketches && this.selectedSketches.indexOf(id) !== -1) ||
focusOfEditing
) {
- layers.push(
- ...([
- {
- // eslint-disable-next-line i18next/no-literal-string
- id: `sketch-${id}-selection-second-outline`,
- type: "line",
- source,
- paint: {
- "line-color": "white",
- "line-opacity": 0.25,
- "line-width": 6,
- "line-blur": 0,
- "line-offset": -3,
- },
- // layout: { "line-join": "miter" },
+ if (
+ "metadata" in layers[0] &&
+ layers[0].metadata?.["s:filterApiServerLocation"]?.length
+ ) {
+ const outline = {
+ // eslint-disable-next-line i18next/no-literal-string
+ id: `sketch-${id}-selection-outline`,
+ type: "line",
+ source,
+ "source-layer": "cells",
+ paint: {
+ "line-color": "rgb(46, 115, 182)",
+ "line-opacity": 0.7,
+ "line-width": 1,
+ "line-blur": 0,
+ // "line-offset": -1,
},
- {
- // eslint-disable-next-line i18next/no-literal-string
- id: `sketch-${id}-selection-outline`,
- type: "line",
- source,
- paint: {
- "line-color": "rgb(46, 115, 182)",
- "line-opacity": 1,
- "line-width": 2,
- "line-blur": 0,
- "line-offset": -1,
+ // @ts-ignore
+ slot: "top",
+ layout: {},
+ // layout: { "line-join": "miter" },
+ } as LineLayer;
+ layers.push(outline);
+ } else {
+ layers.push(
+ ...([
+ {
+ // eslint-disable-next-line i18next/no-literal-string
+ id: `sketch-${id}-selection-second-outline`,
+ type: "line",
+ source,
+ paint: {
+ "line-color": "white",
+ "line-opacity": 0.25,
+ "line-width": 6,
+ "line-blur": 0,
+ "line-offset": -3,
+ },
+ // layout: { "line-join": "miter" },
},
- // layout: { "line-join": "miter" },
- },
- ] as AnyLayer[])
- );
+ {
+ // eslint-disable-next-line i18next/no-literal-string
+ id: `sketch-${id}-selection-outline`,
+ type: "line",
+ source,
+ paint: {
+ "line-color": "rgb(46, 115, 182)",
+ "line-opacity": 1,
+ "line-width": 2,
+ "line-blur": 0,
+ "line-offset": -1,
+ },
+ // layout: { "line-join": "miter" },
+ },
+ ] as AnyLayer[])
+ );
+ }
}
return layers;
}
diff --git a/packages/client/src/draw/useMapboxGLDraw.ts b/packages/client/src/draw/useMapboxGLDraw.ts
index d8998568..4900ac9c 100644
--- a/packages/client/src/draw/useMapboxGLDraw.ts
+++ b/packages/client/src/draw/useMapboxGLDraw.ts
@@ -717,6 +717,8 @@ function glDrawMode(
return "draw_polygon";
} else if (geometryType === SketchGeometryType.Collection) {
return "simple_select";
+ } else if (geometryType === SketchGeometryType.FilteredPlanningUnits) {
+ return "simple_select";
}
throw new Error("Not implemented");
}
diff --git a/packages/client/src/formElements/CollapsibleGroup.tsx b/packages/client/src/formElements/CollapsibleGroup.tsx
new file mode 100644
index 00000000..9b1843ef
--- /dev/null
+++ b/packages/client/src/formElements/CollapsibleGroup.tsx
@@ -0,0 +1,162 @@
+import { Trans, useTranslation } from "react-i18next";
+import { FormElementBody, FormElementComponent } from "./FormElement";
+import {
+ DropdownMenuIcon,
+ MinusCircledIcon,
+ PlusCircledIcon,
+} from "@radix-ui/react-icons";
+import fromMarkdown from "./fromMarkdown";
+import { useEffect, useState } from "react";
+import Warning from "../components/Warning";
+import useDialog from "../components/useDialog";
+import * as Tooltip from "@radix-ui/react-tooltip";
+
+/**
+ * Displays a rich text section
+ */
+const CollapsibleGroup: FormElementComponent<{
+ defaultOpen?: boolean;
+}> = (props) => {
+ const { t } = useTranslation("admin:surveys");
+ const [open, setOpen] = useState(
+ props.editable || Boolean(props.componentSettings.defaultOpen)
+ );
+
+ useEffect(() => {
+ if (props.onCollapse) {
+ props.onCollapse(open);
+ }
+ }, []);
+
+ const { alert } = useDialog();
+ return (
+ <>
+ {!props.editable && (
+
+
{
+ setOpen((prev) => !prev);
+ if (props.onCollapse) {
+ props.onCollapse(!open);
+ }
+ }}
+ className="flex-1 prosemirror-body cursor-pointer flex items-center"
+ >
+
+
+
+ {props.body.content[0].content[0].text}
+ {props.collapsibleGroupState?.active ? (
+
+
+
+ ) : null}
+
+
+
+
+ This group has input values.
+
+ Click to expand.
+
+
+
+
+
+ {open ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+ <>
+ {props.editable && (
+
+
{
+ setOpen(false);
+ if (props.onCollapse) {
+ props.onCollapse(false);
+ }
+ }}
+ />
+
+
{
+ alert(t("How do Collapsible Groups work?"), {
+ description: (
+
+
+ Collapsible Groups enable users to hide and show a
+ long list of questions or content. This is useful for
+ organizing content into sections that can be expanded
+ or collapsed.
+
+
+ All form elements that are below a Collapsible Group
+ will be controlled by that group, until the next
+ Collapsible Group (or Collapsible Break) is
+ encountered.
+
+
+ ),
+ });
+ }}
+ className="text-xs text-primary-500 underline"
+ >
+
+ How do Collapsible Groups work?
+
+
+
+
+ )}
+ {props.body.content.length > 1 && props.editable && (
+
+
+ Extra content beyond the first heading will be ignored for
+ Collapsible Groups
+
+
+ )}
+ >
+ >
+ );
+};
+
+// eslint-disable-next-line i18next/no-literal-string
+CollapsibleGroup.defaultBody = fromMarkdown(`
+# Group Name
+`);
+
+CollapsibleGroup.label =
Collapsible Group ;
+CollapsibleGroup.description = (
+
Hide and show elements
+);
+
+CollapsibleGroup.icon = () => (
+
+
+
+);
+
+export default CollapsibleGroup;
diff --git a/packages/client/src/formElements/DigitizingTools.tsx b/packages/client/src/formElements/DigitizingTools.tsx
index 0224122c..5a332bc6 100644
--- a/packages/client/src/formElements/DigitizingTools.tsx
+++ b/packages/client/src/formElements/DigitizingTools.tsx
@@ -88,8 +88,9 @@ const DigitizingTools: FunctionComponent
= ({
= ({
/>
)}
{(state === DigitizingState.EDITING ||
- (selfIntersects && (state !== DigitizingState.CAN_COMPLETE)) ||
+ (selfIntersects && state !== DigitizingState.CAN_COMPLETE) ||
preprocessingError) && (
- }
- onClick={onRequestDelete}
- className={`pointer-events-auto`}
- buttonClassName={
- bottomToolbar ? "py-3 flex-1 justify-center content-center" : ""
- }
- />
- )}
+ }
+ onClick={onRequestDelete}
+ className={`pointer-events-auto`}
+ buttonClassName={
+ bottomToolbar ? "py-3 flex-1 justify-center content-center" : ""
+ }
+ />
+ )}
{state === DigitizingState.EDITING && !selfIntersects && (
{
@@ -120,8 +121,9 @@ const DigitizingTools: FunctionComponent = ({
}}
primary
label={t("Done Editing")}
- className={`pointer-events-auto whitespace-nowrap ${bottomToolbar && "flex-2 content-center max-w-1/2"
- }`}
+ className={`pointer-events-auto whitespace-nowrap ${
+ bottomToolbar && "flex-2 content-center max-w-1/2"
+ }`}
buttonClassName={
bottomToolbar
? "py-3 text-base flex-1 text-center items-center justify-center"
@@ -135,8 +137,9 @@ const DigitizingTools: FunctionComponent = ({
onRequestFinishEditing(false);
}}
label={t("Resubmit")}
- className={`pointer-events-auto whitespace-nowrap ${bottomToolbar && "flex-2 content-center max-w-1/2"
- }`}
+ className={`pointer-events-auto whitespace-nowrap ${
+ bottomToolbar && "flex-2 content-center max-w-1/2"
+ }`}
primary
buttonClassName={
bottomToolbar
@@ -152,8 +155,9 @@ const DigitizingTools: FunctionComponent = ({
setShowInvalidShapeModal(true);
}}
label={t("Invalid Shape")}
- className={`pointer-events-auto whitespace-nowrap ${bottomToolbar && "flex-2 content-center max-w-1/2"
- }`}
+ className={`pointer-events-auto whitespace-nowrap ${
+ bottomToolbar && "flex-2 content-center max-w-1/2"
+ }`}
buttonClassName={
bottomToolbar
? "py-3 text-base flex-1 text-center items-center justify-center border-red-800 bg-red-50 text-red-900 hover:text-red-700"
@@ -240,36 +244,40 @@ const DigitizingTools: FunctionComponent = ({
}
/>
-
-
- {isSketchingWorkflow &&
- state === DigitizingState.NO_SELECTION &&
- geometryType !== SketchGeometryType.Collection ? (
- Click your sketch to edit geometry
- ) : (
-
- )}
-
-
- {buttons}
-
-
+ {geometryType !== SketchGeometryType.Collection &&
+ geometryType !== SketchGeometryType.FilteredPlanningUnits && (
+
+
+ {isSketchingWorkflow &&
+ state === DigitizingState.NO_SELECTION ? (
+
+ Click your sketch to edit geometry
+
+ ) : (
+
+ )}
+
+
+ {buttons}
+
+
+ )}
= (
+ props
+) => {
+ const { t } = useTranslation("surveys");
+ const { error, loading, metadata } = useFilterContext(
+ props.componentSettings.attribute
+ );
+
+ const handleChange = useCallback(
+ (value: Partial) => {
+ const newState = {
+ ...(props.value as FilterInputValue),
+ ...value,
+ attribute: props.componentSettings.attribute,
+ } as FilterInputValue;
+ if (props.onChange) {
+ props.onChange(newState, false, false);
+ }
+ },
+ [props.onChange, props.value]
+ );
+
+ return (
+ <>
+
+ {!Boolean(props.value?.selected) && !props.editable && (
+
{
+ if (
+ metadata?.type === "boolean" &&
+ !("booleanState" in (props.value || {}))
+ ) {
+ handleChange({
+ selected: true,
+ booleanState: true,
+ });
+ } else {
+ handleChange({
+ selected: true,
+ });
+ }
+ }}
+ >
+
{props.body.content[0].content[0].text}
+
+ )}
+
{
+ if (
+ metadata?.type === "boolean" &&
+ val === true &&
+ !("booleanState" in (props.value || {}))
+ ) {
+ handleChange({
+ selected: val,
+ booleanState: true,
+ });
+ } else {
+ handleChange({
+ selected: val,
+ });
+ }
+ }}
+ />
+
+
+ <>
+ {(Boolean(props.value?.selected) || props.editable) && (
+
+ {
+ handleChange({
+ selected: false,
+ });
+ }
+ : undefined
+ }
+ />
+ {props.editable && (
+
+
+ {metadata?.type} input
+
+
+
+
+
+
+
+
+
+
+ The appropriate filter input will be shown to end-users
+ when this property is selected.
+
+
+
+
+ Inputs are not rendered in when editing from the admin
+ interface for performance reasons.
+
+
+
+
+
+
+ )}
+ {!props.editable && (
+ <>
+ {metadata?.type === "boolean" && (
+ {
+ handleChange({
+ booleanState: value === true,
+ });
+ }}
+ />
+ )}
+ {metadata?.type === "number" && (
+ {
+ handleChange({
+ numberState: {
+ min: value[0],
+ max: value[1],
+ },
+ });
+ }}
+ />
+ )}
+ {metadata?.type === "string" && (
+ {
+ handleChange({
+ stringState: value,
+ });
+ }}
+ />
+ )}
+ >
+ )}
+
+ )}
+
+ {/* {props.value?.selected && (
+
+
+ {props.componentSettings.attribute}
+
+
+ )} */}
+ >
+
+ {
+ return <>>;
+ }}
+ />
+ >
+ );
+};
+
+FilterInput.label = Filter Input ;
+FilterInput.description = (
+ Filter planning units by attribute
+);
+FilterInput.defaultBody = questionBodyFromMarkdown(`
+#
+`);
+FilterInput.defaultComponentSettings = {
+ attribute: "",
+};
+FilterInput.advanceAutomatically = false;
+
+FilterInput.icon = () => (
+
+
+
+);
+
+FilterInput.templatesOnly = true;
+
+export default FilterInput;
+
+export function BooleanInput({
+ metadata,
+ value,
+ onChange,
+}: {
+ metadata: FilterGeostatsAttribute;
+ value: boolean;
+ onChange: (value: boolean) => void;
+}) {
+ const handleChange: ChangeEventHandler = useCallback(
+ (e) => {
+ onChange(e.target.value === "true");
+ },
+ [onChange]
+ );
+
+ return (
+
+ );
+}
+
+export function NumberConfig({
+ metadata,
+ value,
+ onChange,
+}: {
+ metadata: FilterGeostatsAttribute;
+ value: [number, number];
+ onChange: (value: [number, number]) => void;
+}) {
+ const min = "min" in metadata ? metadata.min! : 0;
+ const max = "max" in metadata ? metadata.max! : 1;
+ return (
+
+ {/* histogram */}
+ {metadata.stats?.histogram && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ Use the sliders above to filter between a range of values in the
+ histogram, or set exact values in the inputs below.
+
+
+
+ {
+ onChange([Number(e.target.value), value[1]]);
+ }}
+ />
+ {
+ onChange([value[0], Number(e.target.value)]);
+ }}
+ />
+
+
+ );
+}
+
+function Histogram({
+ data,
+ ...props
+}: {
+ data: (number | null)[][];
+ min: number;
+ max: number;
+}) {
+ const max = useMemo(() => {
+ return Math.max(...data.map((d) => d[1] || 0));
+ }, [data]);
+
+ return (
+
+ {data.map((d, i) => {
+ // const value = d[0];
+ const count = d[1];
+ const height = count ? `${(count / max) * 100}%` : "0%";
+ return (
+
= props.min && d[0] < props.max
+ ? "bg-primary-500"
+ : "bg-primary-500 bg-opacity-50"
+ }
+ style={{ height, width: "2%" }}
+ >
+ );
+ })}
+
+ );
+}
+
+export function StringConfig({
+ value,
+ metadata,
+ onChange,
+}: {
+ metadata: FilterGeostatsAttribute;
+ value: string[];
+ onChange: (value: string[]) => void;
+}) {
+ return (
+
+
+
+ Select one or more options to limit cells to those with matching
+ values. If no selection is made, this filter will not be applied.
+
+
+
{
+ const values = Array.from(e.target.selectedOptions).map(
+ (option) => option.value
+ );
+ onChange(values);
+ }}
+ multiple
+ value={value || []}
+ className="w-full text-sm p-2 rounded bg-gray-100 border-gray-300"
+ style={{ height: Object.keys(metadata.values).length * 18 + 18 }}
+ >
+ {Object.keys(metadata.values).map((key) => (
+
+ {key} - {/* eslint-disable-next-line i18next/no-literal-string */}
+ {metadata.values[key].toLocaleString()} cells
+
+ ))}
+
+
+ );
+}
+
+function valuesFromNumberState(
+ value: { min?: number; max?: number } | undefined,
+ metadata: FilterGeostatsAttribute
+): [number, number] {
+ return [
+ value && "min" in value
+ ? value.min!
+ : "min" in metadata
+ ? metadata.min!
+ : 0,
+ value && "max" in value
+ ? value.max!
+ : "max" in metadata
+ ? metadata.max!
+ : 1,
+ ];
+}
diff --git a/packages/client/src/formElements/FilterInputContext.tsx b/packages/client/src/formElements/FilterInputContext.tsx
new file mode 100644
index 00000000..d39b3e98
--- /dev/null
+++ b/packages/client/src/formElements/FilterInputContext.tsx
@@ -0,0 +1,365 @@
+import {
+ GeostatsAttribute,
+ NumericGeostatsAttribute,
+} from "@seasketch/geostats-types";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { FilterLayerManager } from "./FilterLayerManager";
+import { MapContext } from "../dataLayers/MapContextManager";
+import { FormElementDetailsFragment } from "../generated/graphql";
+
+export type FilterGeostatsAttribute = Pick<
+ GeostatsAttribute,
+ "attribute" | "type" | "max" | "min" | "values"
+> & {
+ stats?: Pick<
+ NumericGeostatsAttribute["stats"],
+ "avg" | "stdev" | "histogram"
+ >;
+};
+
+export type FilterServiceMetadata = {
+ version: number;
+ attributes: FilterGeostatsAttribute[];
+};
+
+export type FilterInputValue = {
+ attribute: string;
+ selected: boolean;
+ numberState?: {
+ min?: number;
+ max?: number;
+ };
+ stringState?: string[];
+ booleanState?: boolean;
+};
+
+export const FilterInputServiceContext = createContext<{
+ metadata?: FilterServiceMetadata;
+ loading: boolean;
+ error?: Error;
+ getAttributeDetails: (attribute: string) => null | FilterGeostatsAttribute;
+ updatingCount: boolean;
+ count: number;
+ fullCellCount: number;
+}>({
+ loading: false,
+ getAttributeDetails: () => null,
+ updatingCount: true,
+ count: 0,
+ fullCellCount: 0,
+});
+
+export function FilterInputServiceContextProvider({
+ children,
+ serviceLocation,
+ startingProperties,
+ formElements,
+ skipMap,
+}: {
+ children: React.ReactNode;
+ serviceLocation?: string;
+ startingProperties?: { [key: string]: FilterInputValue };
+ formElements: Pick<
+ FormElementDetailsFragment,
+ "id" | "componentSettings" | "typeId"
+ >[];
+ skipMap?: boolean;
+}) {
+ const [state, setState] = useState<{
+ metadata?: FilterServiceMetadata;
+ loading: boolean;
+ error?: Error;
+ updatingCount: boolean;
+ count: number;
+ fullCellCount: number;
+ filterString: string;
+ }>({
+ loading: false,
+ updatingCount: true,
+ count: 0,
+ fullCellCount: 0,
+ filterString: "",
+ });
+
+ const mapContext = useContext(MapContext);
+
+ const getAttributeDetails = useCallback(
+ (attribute: string) => {
+ return (
+ state.metadata?.attributes.find((a) => a.attribute === attribute) ||
+ null
+ );
+ },
+ [state.metadata]
+ );
+
+ useEffect(() => {
+ if (!serviceLocation) {
+ return;
+ }
+ setState((prev) => ({ ...prev, loading: true }));
+ fetch(`${serviceLocation.replace(/\/$/, "")}/metadata`)
+ .then((res) => res.json())
+ .then((metadata: FilterServiceMetadata) => {
+ setState((prev) => {
+ const count =
+ // @ts-ignore
+ metadata.attributes.find((a) => a.attribute === "id")?.count || 0;
+ return {
+ ...prev,
+ metadata,
+ loading: false,
+ fullCellCount: count,
+ count,
+ updatingCount: false,
+ };
+ });
+ })
+ .catch((error) => {
+ setState((prev) => ({ ...prev, error, loading: false }));
+ });
+ }, [serviceLocation]);
+
+ const [filterLayerManager, setFilterLayerManager] = useState<
+ FilterLayerManager | undefined
+ >();
+
+ useEffect(() => {
+ if (mapContext.manager && state.metadata && serviceLocation && !skipMap) {
+ const mngr = new FilterLayerManager(
+ serviceLocation,
+ state.metadata,
+ mapContext.manager,
+ filterStateToSearchString(startingProperties || {})
+ );
+ setFilterLayerManager(mngr);
+ return () => {
+ mngr.destroy();
+ };
+ }
+ }, [mapContext.manager, state.metadata, serviceLocation]);
+
+ const value = useMemo(() => {
+ return {
+ ...state,
+ getAttributeDetails,
+ };
+ }, [state, getAttributeDetails]);
+
+ useEffect(() => {
+ if (startingProperties && state.metadata) {
+ if (filterLayerManager) {
+ const filterString = filterStateToSearchString(
+ filterDefaults(
+ initialFilterState(startingProperties, formElements),
+ state.metadata
+ )
+ );
+ filterLayerManager.updateFilter(filterString);
+ if (filterString === state.filterString) {
+ // do nothing
+ } else if (filterString.length === 0) {
+ setState((prev) => ({
+ ...prev,
+ updatingCount: false,
+ count: prev.fullCellCount,
+ filterString: "",
+ }));
+ } else if (serviceLocation) {
+ // TODO: update count from service
+ setState((prev) => ({
+ ...prev,
+ updatingCount: true,
+ filterString,
+ }));
+ fetch(
+ `${serviceLocation.replace(/\/$/, "")}/count?filter=${filterString}`
+ )
+ .then((res) => res.json())
+ .then((data) => {
+ setState((prev) => ({
+ ...prev,
+ updatingCount: false,
+ count: data.count,
+ }));
+ })
+ .catch((error) => {
+ setState((prev) => ({
+ ...prev,
+ updatingCount: false,
+ error,
+ }));
+ });
+ }
+ }
+ }
+ }, [
+ startingProperties,
+ state.metadata,
+ filterLayerManager,
+ formElements,
+ serviceLocation,
+ ]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useFilterContext(attribute: string) {
+ const context = useContext(FilterInputServiceContext);
+ const metadata = context.getAttributeDetails(attribute);
+ return {
+ metadata,
+ loading: context.loading,
+ error: context.error,
+ };
+}
+
+type FilterState = { [key: string]: FilterInputValue };
+
+/**
+ * References attributes.json to determine if filter state is matching defaults.
+ * If so, the filter state should be simplified or removed from the filtered
+ * state.
+ * For example, if a number filter is set to a threshold minimum but the maximum
+ * is just set to the maximum in the attributes data, remove the max setting.
+ * @param filterParams FilterState
+ * @returns FilterState
+ */
+function filterDefaults(
+ filterParams: FilterState,
+ metadata: FilterServiceMetadata
+): FilterState {
+ const attributes = metadata.attributes;
+ const output: FilterState = {};
+ for (const key in filterParams) {
+ const attr = attributes.find((a) => a.attribute === key);
+ const state = filterParams[key];
+ if (!attr) {
+ throw new Error(`Attribute ${key} not found in metadata`);
+ }
+ if (!state.selected) {
+ continue;
+ }
+ if (attr.type === "number") {
+ const filter = state.numberState;
+ if (!filter) {
+ continue;
+ }
+ if (filter.min !== attr.min && filter.max !== attr.max) {
+ output[key] = state;
+ } else if (filter.min !== attr.min) {
+ output[key] = {
+ ...state,
+ numberState: {
+ min: filter.min,
+ },
+ };
+ } else if (filter.max !== attr.max) {
+ output[key] = {
+ ...state,
+ numberState: {
+ max: filter.max,
+ },
+ };
+ } else {
+ continue;
+ }
+ } else if (attr.type === "string") {
+ const filter = state.stringState;
+ if (!filter) {
+ continue;
+ }
+ if (
+ filter.length &&
+ filter.length !== Object.keys(attr.values || {}).length
+ ) {
+ output[key] = state;
+ }
+ } else if (attr.type === "boolean") {
+ const filter = state.booleanState;
+ if (filter !== undefined) {
+ output[key] = state;
+ }
+ }
+ }
+ return output;
+}
+
+export function filterStateToSearchString(filters: FilterState) {
+ const state: {
+ [attribute: string]:
+ | {
+ min?: number;
+ max?: number;
+ }
+ | { choices: string[] }
+ | { bool: boolean };
+ } = {};
+ for (const attr in filters) {
+ const filter = filters[attr];
+ if (filter && filter.selected) {
+ if ("numberState" in filter) {
+ state[attr] = filter.numberState!;
+ } else if ("stringState" in filter) {
+ state[attr] = { choices: filter.stringState || [] };
+ } else if (
+ "booleanState" in filter &&
+ filter.booleanState !== undefined
+ ) {
+ state[attr] = { bool: filter.booleanState || false };
+ }
+ }
+ }
+ if (Object.keys(state).length === 0) {
+ return "";
+ } else {
+ // eslint-disable-next-line i18next/no-literal-string
+ return encodeURIComponent(JSON.stringify(state));
+ }
+}
+
+function initialFilterState(
+ values: {
+ [key: string]: FilterInputValue;
+ },
+ formElements: Pick<
+ FormElementDetailsFragment,
+ "id" | "componentSettings" | "typeId"
+ >[]
+): FilterState {
+ const state: FilterState = {};
+ if (!values) {
+ return {};
+ } else {
+ for (const key in values) {
+ const element = formElements.find((e) => e.id === parseInt(key));
+ if (
+ values[key] &&
+ element?.typeId === "FilterInput" &&
+ element.componentSettings?.attribute
+ ) {
+ const value = values[key];
+ if (
+ value.selected &&
+ ("numberState" in value ||
+ "stringState" in value ||
+ "booleanState" in value)
+ ) {
+ state[element.componentSettings.attribute] = value;
+ }
+ }
+ }
+ }
+ return state;
+}
diff --git a/packages/client/src/formElements/FilterLayerManager.ts b/packages/client/src/formElements/FilterLayerManager.ts
new file mode 100644
index 00000000..c6fcd011
--- /dev/null
+++ b/packages/client/src/formElements/FilterLayerManager.ts
@@ -0,0 +1,261 @@
+import { FillLayer, VectorSource } from "mapbox-gl";
+import MapContextManager from "../dataLayers/MapContextManager";
+import { FilterServiceMetadata } from "./FilterInputContext";
+import * as d3Colors from "d3-scale-chromatic";
+import debounce from "lodash.debounce";
+
+const colorScale = d3Colors.interpolateViridis;
+
+const colors = {
+ highlight: "rgba(255, 125, 0, 0.8)",
+ empty: "rgba(100, 100, 100, 0.2)",
+ stale: "rgba(155, 125, 100, 0.5)",
+};
+function colorScaleForResolution(resolution: number) {
+ const scale = colorScale;
+ const maxValue = 7 ** (11 - resolution);
+ return [
+ "interpolate",
+ ["linear"],
+ ["get", "count"],
+ 0,
+ scale(0),
+ (maxValue / 10) * 2,
+ scale(0.2),
+ (maxValue / 10) * 4,
+ scale(0.4),
+ (maxValue / 10) * 6,
+ scale(0.6),
+ (maxValue / 10) * 8,
+ scale(0.8),
+ maxValue,
+ scale(1),
+ ];
+}
+
+export const LayerTemplate = {
+ type: "fill",
+ slot: "top",
+ source: "all-cells",
+ "source-layer": "cells",
+ paint: {
+ "fill-color": [
+ "case",
+ ["has", "highlighted"],
+ [
+ "case",
+ ["to-boolean", ["get", "highlighted"]],
+ [
+ "case",
+ ["==", ["get", "resolution"], 6],
+ colorScaleForResolution(6),
+ ["==", ["get", "resolution"], 7],
+ colorScaleForResolution(7),
+ ["==", ["get", "resolution"], 8],
+ colorScaleForResolution(8),
+ ["==", ["get", "resolution"], 9],
+ colorScaleForResolution(9),
+ ["==", ["get", "resolution"], 10],
+ colorScaleForResolution(10),
+ ["==", ["get", "resolution"], 11],
+ colorScaleForResolution(11),
+ colorScale(1),
+ ],
+ colors.empty,
+ ],
+ colorScale(1),
+ ],
+ "fill-outline-color": "black",
+ "fill-opacity": 0.8,
+ },
+};
+
+// TODO: this should be derived from the metadata once the service is updated
+const STATIC_TILESET_URL = `https://tiles.seasketch.org/crdss-cells-6/{z}/{x}/{y}.pbf`;
+
+export class FilterLayerManager {
+ private metadata: FilterServiceMetadata;
+ private location: string;
+ private mapContext: MapContextManager;
+ private filterString = "";
+ private currentLayerCount = 0;
+ // eslint-disable-next-line i18next/no-literal-string
+ private layerId = `filter-layer-${Math.random().toString(36).substring(7)}`;
+
+ constructor(
+ location: string,
+ metadata: FilterServiceMetadata,
+ MapContextManager: MapContextManager,
+ filterString?: string
+ ) {
+ this.metadata = metadata;
+ this.location = location.replace(/\/$/, "");
+ this.mapContext = MapContextManager;
+ this.filterString = filterString || "";
+ // add all-cells source
+ if (!this.mapContext?.map) {
+ throw new Error("Map not initialized");
+ }
+ if (!this.mapContext.map.getSource("all-cells")) {
+ this.mapContext.map.addSource("all-cells", {
+ type: "vector",
+ tiles: [STATIC_TILESET_URL],
+ // TODO: this should be set by metadata. Maybe the whole source definition
+ // should be passed in?
+ maxzoom: 14,
+ });
+ this.mapContext.map.addLayer({
+ // eslint-disable-next-line i18next/no-literal-string
+ id: `all-cells`,
+ ...LayerTemplate,
+ // TODO: source layer should be set by metadata
+ source: "all-cells",
+ paint: {
+ ...LayerTemplate.paint,
+ "fill-color": colors.empty,
+ "fill-opacity": 0.5,
+ },
+ } as FillLayer);
+ }
+ this.mapContext.map.addLayer({
+ id: `${this.layerId}-${this.currentLayerCount}`,
+ ...LayerTemplate,
+ } as FillLayer);
+ this.updateLayer();
+ }
+
+ destroy() {
+ if (!this.mapContext?.map) {
+ throw new Error("Map not initialized");
+ }
+ for (const layer of this.mapContext.map.getStyle().layers || []) {
+ if (layer.id.startsWith(this.layerId)) {
+ this.mapContext.map.removeLayer(layer.id);
+ if ("source" in layer && layer.source !== "all-cells") {
+ this.mapContext.map.removeSource(layer.source as string);
+ }
+ }
+ }
+ this.mapContext.map.removeLayer("all-cells");
+ this.mapContext.map.removeSource("all-cells");
+ }
+
+ updateFilter(filter: string) {
+ if (this.filterString !== filter) {
+ this.filterString = filter;
+ this.debouncedUpdateLayer();
+ }
+ }
+
+ debouncedUpdateLayer = debounce(this.updateLayer, 50);
+
+ updateLayer() {
+ if (!this.mapContext?.map) {
+ throw new Error("Map not initialized");
+ }
+ const map = this.mapContext.map;
+ const ac = new AbortController();
+ // eslint-disable-next-line i18next/no-literal-string
+ let url = `${this.location}/v${this.metadata.version}/mvt/{z}/{x}/{y}.pbf`;
+ if (this.filterString.length > 0) {
+ // eslint-disable-next-line i18next/no-literal-string
+ url += `?filter=${this.filterString}`;
+ } else {
+ url = STATIC_TILESET_URL;
+ }
+ const currentLayerId = `${this.layerId}-${this.currentLayerCount}`;
+ try {
+ const currentLayer = map.getLayer(currentLayerId) as FillLayer;
+ if (currentLayer && currentLayer.source) {
+ const currentSource = map.getSource(
+ currentLayer.source as string
+ ) as mapboxgl.VectorSource;
+ if (currentLayer && currentSource && currentSource.tiles?.[0] !== url) {
+ map.setPaintProperty(currentLayerId, "fill-color", [
+ "case",
+ ["to-boolean", ["get", "highlighted"]],
+ colors.stale,
+ colors.empty,
+ ]);
+ }
+ this.currentLayerCount++;
+ if (this.currentLayerCount > 100) {
+ this.currentLayerCount = 0;
+ }
+ const newLayerId = `${this.layerId}-${this.currentLayerCount}`;
+ // setCellsAreLoading(true);
+ map.addSource(newLayerId, {
+ type: "vector",
+ tiles: [url],
+ maxzoom: 14,
+ });
+ map.addLayer({
+ id: newLayerId,
+ ...LayerTemplate,
+ source: newLayerId,
+ } as FillLayer);
+
+ map.on("sourcedata", () => {
+ this.debouncedRemoveStaleLayers(map);
+ });
+ this.debouncedRemoveStaleLayers(map);
+
+ setTimeout(() => {
+ this.debouncedRemoveStaleLayers(map);
+ }, 2000);
+
+ // setBaseUrl(url);
+ return () => {
+ ac.abort();
+ };
+ }
+ } catch (e: any) {
+ console.error(e);
+ }
+ }
+
+ removeStaleLayers(map: mapboxgl.Map) {
+ const style = map.getStyle();
+ if (!style) return;
+ const layers = style.layers;
+ if (!layers) {
+ return;
+ }
+ const currentLayerId = `${this.layerId}-${this.currentLayerCount}`;
+ const currentLayer = map.getLayer(currentLayerId) as FillLayer;
+ const regex = new RegExp(`^${this.layerId}`);
+ const staleLayers = layers.filter(
+ (l) => regex.test(l.id) && l.id !== currentLayerId
+ ) as FillLayer[];
+ if (currentLayer && currentLayer.source) {
+ const currentSource = map.getSource(
+ currentLayer.source as string
+ ) as VectorSource;
+ if (currentSource.id && map.isSourceLoaded(currentSource.id)) {
+ // remove all stale layers and sources
+ staleLayers.forEach((layer) => {
+ map.removeLayer(layer.id);
+ if (layer.source !== "all-cells") {
+ map.removeSource(layer.source as string);
+ }
+ });
+ } else if (staleLayers.length > 1) {
+ // There are multiple stale layers. We want to keep the latest one that is
+ // loaded on the map, but remove the others.
+ const loaded = staleLayers.find((l) =>
+ map.isSourceLoaded(l.source as string)
+ );
+ for (const layer of staleLayers) {
+ if (layer !== loaded) {
+ map.removeLayer(layer.id);
+ if (layer.source !== "all-cells") {
+ map.removeSource(layer.source as string);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ debouncedRemoveStaleLayers = debounce(this.removeStaleLayers, 10);
+}
diff --git a/packages/client/src/formElements/FormElement.tsx b/packages/client/src/formElements/FormElement.tsx
index f850e985..8b0e0e7c 100644
--- a/packages/client/src/formElements/FormElement.tsx
+++ b/packages/client/src/formElements/FormElement.tsx
@@ -108,6 +108,8 @@ export interface FormElementProps {
* component.
*/
surveyParticipantCount?: number;
+ onCollapse?: (open: boolean) => void;
+ collapsibleGroupState?: { hidden: boolean; active: boolean };
}
/**
@@ -132,6 +134,7 @@ export function FormElementBody({
componentSettings,
componentSettingName,
alternateLanguageSettings,
+ onHeadingClick,
}: {
formElementId: number;
body: any;
@@ -141,6 +144,11 @@ export function FormElementBody({
componentSettings?: any;
componentSettingName?: string;
alternateLanguageSettings: any;
+ /**
+ * Only available when not in editable mode
+ * @returns
+ */
+ onHeadingClick?: () => void;
}) {
const schema = isInput
? editorConfig.questions.schema
@@ -169,8 +177,14 @@ export function FormElementBody({
Node.fromJSON(schema, body).content
)
);
+ if (onHeadingClick) {
+ target.current.querySelectorAll("h1").forEach((el) => {
+ el.addEventListener("click", onHeadingClick);
+ (el as HTMLHeadingElement).style.cursor = "pointer";
+ });
+ }
}
- }, [target, body, schema]);
+ }, [target, body, schema, onHeadingClick]);
if (editable) {
return (
diff --git a/packages/client/src/formElements/index.ts b/packages/client/src/formElements/index.ts
index c2559001..b319cadd 100644
--- a/packages/client/src/formElements/index.ts
+++ b/packages/client/src/formElements/index.ts
@@ -168,5 +168,7 @@ registerComponent({
registerComponent({ name: "FeatureName" });
registerComponent({ name: "SAPRange" });
registerComponent({ name: "SaveScreen" });
+registerComponent({ name: "FilterInput" });
+registerComponent({ name: "CollapsibleGroup" });
export { components, componentExportHelpers };
diff --git a/packages/client/src/generated/graphql.ts b/packages/client/src/generated/graphql.ts
index 106d7959..b7a2da46 100644
--- a/packages/client/src/generated/graphql.ts
+++ b/packages/client/src/generated/graphql.ts
@@ -12086,6 +12086,7 @@ export type Sketch = Node & {
*/
copyOf?: Maybe;
createdAt: Scalars['Datetime'];
+ filterMvtUrl?: Maybe;
/** Parent folder. Both regular sketches and collections may be nested within folders for organization purposes. */
folderId?: Maybe;
/** Reads a single `FormElement` that is related to this `Sketch`. */
@@ -12167,6 +12168,8 @@ export type SketchClass = Node & {
* sketch classes can only be digitized by admins.
*/
canDigitize?: Maybe;
+ filterApiServerLocation?: Maybe;
+ filterApiVersion: Scalars['Int'];
/** Reads a single `Form` that is related to this `SketchClass`. */
form?: Maybe
) : (
<>
-