From de94756632e427d3eac393ea65a7ba7dd7c540e6 Mon Sep 17 00:00:00 2001
From: conservationtimothy
Date: Thu, 30 Jul 2026 15:37:33 +0100
Subject: [PATCH 1/4] Make alerts secondary datasets source-agnostic beyond
Mapeo
---
components/AlertsDashboard.vue | 120 +++---
components/alerts/IncidentsSidebar.vue | 17 +-
components/config/ConfigAlerts.vue | 16 +-
components/config/ConfigCard.vue | 9 +-
components/shared/DataFeature.vue | 6 +-
components/shared/MapLegend.vue | 4 +-
components/shared/ViewSidebar.vue | 4 +-
composables/useFeatureSelection.ts | 13 +-
composables/useIncidents.ts | 106 ++---
docs/config.md | 10 +-
i18n/locales/en.json | 10 +-
i18n/locales/es.json | 10 +-
i18n/locales/nl.json | 10 +-
i18n/locales/pt.json | 10 +-
i18n/locales/sw.json | 10 +-
i18n/locales/th.json | 10 +-
pages/alerts/[tablename].vue | 6 +-
pages/config/[dataset].vue | 6 +-
pages/config/new/[view_type].vue | 6 +-
server/annotatedCollections/index.ts | 4 +-
server/api/[table]/alerts.ts | 78 ++--
server/api/config/index.get.ts | 16 +-
.../0010_rename_mapeo_category_ids.sql | 20 +
.../migrations/meta/0010_snapshot.json | 368 ++++++++++++++++++
server/database/migrations/meta/_journal.json | 7 +
server/utils/index.ts | 35 ++
tests/db-seed/guardianconnector.sql | 4 +-
tests/unit/components/AlertsDashboard.test.ts | 6 +-
.../unit/components/IncidentsSidebar.test.ts | 6 +-
.../sourceTableDetermination.test.ts | 16 +-
tests/unit/composables/useCopyConfig.test.ts | 6 +-
tests/unit/utils/geoUtils.test.ts | 20 +-
types/index.ts | 9 +-
utils/geoUtils.ts | 39 +-
utils/mapGLHelpers.ts | 6 +-
35 files changed, 758 insertions(+), 265 deletions(-)
create mode 100644 server/database/migrations/0010_rename_mapeo_category_ids.sql
create mode 100644 server/database/migrations/meta/0010_snapshot.json
diff --git a/components/AlertsDashboard.vue b/components/AlertsDashboard.vue
index cbf7365e..7cd46ba1 100644
--- a/components/AlertsDashboard.vue
+++ b/components/AlertsDashboard.vue
@@ -103,7 +103,7 @@ const props = defineProps<{
mapboxZoom: number;
mapbox3d: boolean;
mapbox3dTerrainExaggeration?: number | null | undefined;
- mapeoData: FeatureCollection | null;
+ secondaryData: FeatureCollection | null;
primaryDataset: string;
secondaryDataset?: string | null;
mediaBasePath: string | undefined;
@@ -129,7 +129,7 @@ const showSlider = ref(false);
const route = useRoute();
const router = useRouter();
-const isMapeo = ref(false);
+const isSecondary = ref(false);
const selectedFeatureLoading = ref(false);
const { fetchRecord } = useRecordCache();
@@ -197,11 +197,11 @@ const {
localAlertsData,
showSidebar,
showIntroPanel,
- isMapeo,
+ isSecondary,
);
// Fetch full record on demand when a minimal feature is selected.
-// Handles both Mapeo features and alert features — the minimal
+// Handles both secondary features and alert features — the minimal
// FeatureCollection only carries IDs and map-rendering fields, so
// the full record is loaded via the single-record endpoint.
let skipNextWatch = false;
@@ -214,15 +214,15 @@ watch(
}
if (!feature) return;
- const isMapeoFeature = isMapeo.value && props.secondaryDataset;
- const isMinimalAlert = !isMapeo.value && feature.alertID && feature._id;
+ const isSecondaryFeature = isSecondary.value && props.secondaryDataset;
+ const isMinimalAlert = !isSecondary.value && feature.alertID && feature._id;
- if (!isMapeoFeature && !isMinimalAlert) return;
+ if (!isSecondaryFeature && !isMinimalAlert) return;
const recordId = feature._id || feature.id;
if (!recordId) return;
- const fetchTable = isMapeoFeature
+ const fetchTable = isSecondaryFeature
? props.secondaryDataset!
: props.primaryDataset;
const minimalFeature = { ...feature };
@@ -233,7 +233,7 @@ watch(
skipNextWatch = true;
let displayRecord: Record;
- if (isMapeoFeature) {
+ if (isSecondaryFeature) {
displayRecord = fullRecord
? transformSurveyEntry(fullRecord)
: minimalFeature;
@@ -328,26 +328,26 @@ const selectInitialAlertFeature = (alertId: string) => {
);
map.value.flyTo({ center: [lng, lat], zoom: 15 });
}
- isMapeo.value = false;
+ isSecondary.value = false;
}
};
/**
- * Selects and zooms to a Mapeo feature based on its document ID.
- * Mapeo data arrives as a GeoJSON FeatureCollection with normalized numeric IDs
+ * Selects and zooms to a secondary feature based on its document ID.
+ * Secondary data arrives as a GeoJSON FeatureCollection with normalized numeric IDs
* (via MurmurHash) for Mapbox feature-state compatibility.
*/
-const selectInitialMapeoFeature = (mapeoDocId: string) => {
- const feature = props.mapeoData?.features.find(
- (mapeoFeature) =>
- mapeoFeature.properties?._id === mapeoDocId ||
- mapeoFeature.properties?.id === mapeoDocId,
+const selectInitialSecondaryFeature = (secondaryDocId: string) => {
+ const feature = props.secondaryData?.features.find(
+ (secondaryFeature) =>
+ secondaryFeature.properties?._id === secondaryDocId ||
+ secondaryFeature.properties?.id === secondaryDocId,
);
if (!feature) return;
- selectFeature(feature, "mapeo-data");
- isMapeo.value = true;
+ selectFeature(feature, "secondary-data");
+ isSecondary.value = true;
// Zoom to the feature
if (feature.geometry.type === "Point") {
const [lng, lat] = (feature.geometry as GeoJSON.Point).coordinates;
@@ -430,14 +430,24 @@ onMounted(() => {
// Only show slider if there are date options
showSlider.value = dateOptions.value && dateOptions.value.length > 0;
- // Check for alertId or mapeoDocId in URL and select the corresponding feature
+ // Check for alertId or secondaryDocId in URL and select the corresponding feature
const alertId = route.query.alertId as string;
- const mapeoDocId = route.query.mapeoDocId as string;
+ const legacyMapeoDocId = route.query.mapeoDocId as string;
+ const secondaryDocId = (route.query.secondaryDocId ||
+ legacyMapeoDocId) as string;
+
+ // Silently migrate the legacy mapeoDocId param to secondaryDocId
+ if (legacyMapeoDocId && !route.query.secondaryDocId) {
+ const query = { ...route.query };
+ delete query.mapeoDocId;
+ query.secondaryDocId = legacyMapeoDocId;
+ router.replace({ query });
+ }
if (alertId) {
selectInitialAlertFeature(alertId);
- } else if (mapeoDocId && props.mapeoData) {
- selectInitialMapeoFeature(mapeoDocId);
+ } else if (secondaryDocId && props.secondaryData) {
+ selectInitialSecondaryFeature(secondaryDocId);
}
// Load incidents on dashboard initialization
@@ -469,8 +479,8 @@ const emit = defineEmits(["reset-legend-visibility"]);
const featuresUnderCursor = ref(0);
const hasLineStrings = ref(false);
const hasPoints = ref(false);
-const mapeoDataColor = ref();
-const MAPEO_INTERACTIVE_LAYER_IDS = ["mapeo-data"];
+const secondaryDataColor = ref();
+const SECONDARY_INTERACTIVE_LAYER_IDS = ["secondary-data"];
const additionalSelectableLayerIds = computed(() =>
(props.mapLegendLayerIds || "")
.split(",")
@@ -1151,31 +1161,31 @@ const addAlertsData = async () => {
};
/**
- * Adds (optional) Mapeo data to the map as a GeoJSON FeatureCollection source
+ * Adds (optional) secondary data to the map as a GeoJSON FeatureCollection source
* with associated circle and symbol layers.
*/
-const addMapeoData = () => {
- if (!props.mapeoData || props.mapeoData.features.length === 0) {
+const addSecondaryData = () => {
+ if (!props.secondaryData || props.secondaryData.features.length === 0) {
return;
}
- mapeoDataColor.value =
- props.mapeoData.features[0]?.properties?.["filter-color"];
+ secondaryDataColor.value =
+ props.secondaryData.features[0]?.properties?.["filter-color"];
// Add the source to the map
- if (!map.value.getSource("mapeo-data")) {
- map.value.addSource("mapeo-data", {
+ if (!map.value.getSource("secondary-data")) {
+ map.value.addSource("secondary-data", {
type: "geojson",
- data: props.mapeoData,
+ data: props.secondaryData,
});
}
// Add a layer for Point features
- if (!map.value.getLayer("mapeo-data")) {
+ if (!map.value.getLayer("secondary-data")) {
map.value.addLayer({
- id: "mapeo-data",
+ id: "secondary-data",
type: "circle",
- source: "mapeo-data",
+ source: "secondary-data",
filter: ["==", "$type", "Point"],
paint: {
"circle-radius": 6,
@@ -1203,7 +1213,7 @@ const addMapeoData = () => {
}
// Add event listeners
- const interactiveLayers = MAPEO_INTERACTIVE_LAYER_IDS.filter((layerId) =>
+ const interactiveLayers = SECONDARY_INTERACTIVE_LAYER_IDS.filter((layerId) =>
map.value.getLayer(layerId),
);
@@ -1250,7 +1260,7 @@ const addMapeoData = () => {
});
};
/**
- * Prepares the map canvas content by adding alert and Mapeo data,
+ * Prepares the map canvas content by adding alert and secondary data,
* and the map legend.
*/
const prepareMapCanvasContent = async () => {
@@ -1258,8 +1268,8 @@ const prepareMapCanvasContent = async () => {
if (props.alertsData) {
promises.push(addAlertsData());
}
- if (props.mapeoData) {
- promises.push(addMapeoData());
+ if (props.secondaryData) {
+ promises.push(addSecondaryData());
}
await Promise.all(promises);
prepareMapLegendContent();
@@ -1286,7 +1296,7 @@ const handleBufferClick = (e: MapMouseEvent) => {
"previous-alerts-polygon",
"most-recent-alerts-centroids",
"previous-alerts-centroids",
- ...MAPEO_INTERACTIVE_LAYER_IDS,
+ ...SECONDARY_INTERACTIVE_LAYER_IDS,
].filter((layerId) => map.value.getLayer(layerId));
if (directHitLayers.length > 0) {
@@ -1371,13 +1381,13 @@ const prepareMapLegendContent = () => {
map.value.once("idle", () => {
const legendItems: MapLegendItem[] = [];
- // Add mapeo-data layer first to ensure it's always on top
- if (props.mapeoData) {
+ // Add secondary-data layer first to ensure it's always on top
+ if (props.secondaryData) {
legendItems.push({
- id: "mapeo-data",
- name: "Mapeo data",
+ id: "secondary-data",
+ name: "Secondary data",
type: "circle",
- color: mapeoDataColor.value || "#000000",
+ color: secondaryDataColor.value || "#000000",
visible: true,
});
}
@@ -1409,7 +1419,7 @@ const prepareMapLegendContent = () => {
const additionalLayers = prepareMapLegendLayers(
map.value,
props.mapLegendLayerIds,
- mapeoDataColor.value,
+ secondaryDataColor.value,
);
if (additionalLayers) {
legendItems.push(...(additionalLayers as MapLegendItem[]));
@@ -1464,8 +1474,8 @@ const toggleLayerVisibility = (item: MapLegendItem) => {
}
}
});
- } else if (item.id === "mapeo-data") {
- MAPEO_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
+ } else if (item.id === "secondary-data") {
+ SECONDARY_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
if (map.value.getLayer(layerId)) {
map.value.setLayoutProperty(layerId, "visibility", visibility);
}
@@ -1475,7 +1485,7 @@ const toggleLayerVisibility = (item: MapLegendItem) => {
}
});
} else {
- // Handle individual layers (mapeo-data, etc.)
+ // Handle individual layers (secondary-data, etc.)
utilsToggleLayerVisibility(map.value, item);
}
};
@@ -1681,8 +1691,8 @@ const resetToInitialState = () => {
}
}
});
- } else if (item.id === "mapeo-data") {
- MAPEO_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
+ } else if (item.id === "secondary-data") {
+ SECONDARY_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
if (map.value.getLayer(layerId)) {
map.value.setLayoutProperty(layerId, "visibility", visibility);
}
@@ -1696,7 +1706,7 @@ const resetToInitialState = () => {
}
});
} else {
- // Handle individual layers (mapeo-data, etc.)
+ // Handle individual layers (secondary-data, etc.)
utilsToggleLayerVisibility(map.value, item);
}
});
@@ -1729,7 +1739,7 @@ onBeforeUnmount(() => {
:calculate-hectares="calculateHectares"
:date-options="dateOptions"
:export-table-name="
- isMapeo ? secondaryDataset || primaryDataset : primaryDataset
+ isSecondary ? secondaryDataset || primaryDataset : primaryDataset
"
:feature="selectedFeature"
:feature-loading="selectedFeatureLoading"
@@ -1737,7 +1747,7 @@ onBeforeUnmount(() => {
:file-paths="imageUrl"
:geojson-selection="filteredData"
:is-alert="isAlert"
- :is-mapeo="isMapeo"
+ :is-secondary="isSecondary"
:is-alerts-dashboard="true"
:local-alerts-data="localAlertsData"
:logo-url="logoUrl"
diff --git a/components/alerts/IncidentsSidebar.vue b/components/alerts/IncidentsSidebar.vue
index fa9ccdc9..77473d07 100644
--- a/components/alerts/IncidentsSidebar.vue
+++ b/components/alerts/IncidentsSidebar.vue
@@ -13,6 +13,7 @@ import {
buildIncidentMetadataCsv,
triggerTextDownload,
} from "@/utils/incidentHelpers";
+import { isSecondaryFeatureType } from "@/types";
import type {
AnnotatedCollection,
CollectionEntry,
@@ -127,7 +128,11 @@ const downloadIncidentFeatures = () => {
};
const showCreateForm = ref(false);
-const { showCopied, copyLink } = useCopyLink(["alertId", "mapeoDocId"]);
+const { showCopied, copyLink } = useCopyLink([
+ "alertId",
+ "mapeoDocId",
+ "secondaryDocId",
+]);
const formData = ref({
name: "",
description: "",
@@ -175,12 +180,12 @@ const isLoadingMore = computed(() => props.isLoadingMore === true);
const selectedSourceSummary = computed(() => {
const summary = {
alerts: 0,
- mapeoData: 0,
+ secondary: 0,
};
props.selectedSources.forEach((source) => {
- if (source.feature_type === "mapeo") {
- summary.mapeoData += 1;
+ if (isSecondaryFeatureType(source.feature_type)) {
+ summary.secondary += 1;
} else {
summary.alerts += 1;
}
@@ -443,8 +448,8 @@ const handleClose = () => {
{{
- $t("incidents.selectedMapeoCount", {
- count: selectedSourceSummary.mapeoData,
+ $t("incidents.selectedSecondaryCount", {
+ count: selectedSourceSummary.secondary,
})
}}
diff --git a/components/config/ConfigAlerts.vue b/components/config/ConfigAlerts.vue
index 37c0518a..8a0d1273 100644
--- a/components/config/ConfigAlerts.vue
+++ b/components/config/ConfigAlerts.vue
@@ -20,9 +20,14 @@ const emit = defineEmits<{
type Tag = { text: string };
+const resolveCategoryIds = (config: ViewConfig): string | undefined =>
+ config.SECONDARY_CATEGORY_IDS || config.MAPEO_CATEGORY_IDS;
+
+const initialCategoryIds = resolveCategoryIds(props.config);
+
const initialTags: Record = {
- MAPEO_CATEGORY_IDS: props.config.MAPEO_CATEGORY_IDS
- ? props.config.MAPEO_CATEGORY_IDS.split(",").map((tag) => ({ text: tag }))
+ SECONDARY_CATEGORY_IDS: initialCategoryIds
+ ? initialCategoryIds.split(",").map((tag) => ({ text: tag }))
: [],
};
@@ -34,7 +39,10 @@ const { tags, handleTagsChanged: rawHandleTagsChanged } = updateTags(
const handleTagsChanged = (key: string, newTags: Tag[]): void => {
rawHandleTagsChanged(key, newTags);
const values = newTags.map((tag) => tag.text).join(",");
- emit("updateConfig", { [key]: values });
+ emit("updateConfig", {
+ SECONDARY_CATEGORY_IDS: values,
+ MAPEO_CATEGORY_IDS: undefined,
+ });
};
@@ -47,7 +55,7 @@ const handleTagsChanged = (key: string, newTags: Tag[]): void => {
>
{{ $t(toCamelCase(key)) }}
-
+
[
"MEDIA_BASE_PATH_ICONS",
"MEDIA_COLUMN",
]);
-const alertKeys = computed(() => ["MAPEO_CATEGORY_IDS"]);
+const alertKeys = computed(() => ["SECONDARY_CATEGORY_IDS"]);
const filterKeys = computed(() => [
"FILTER_OUT_VALUES_FROM_COLUMN",
"FRONT_END_FILTER_COLUMN",
@@ -82,7 +82,12 @@ const viewTypeList = computed(() => [props.viewType]);
* @returns {ViewConfig} A detached copy of the configuration.
*/
const cloneConfig = (config: ViewConfig): ViewConfig => {
- return JSON.parse(JSON.stringify(config)) as ViewConfig;
+ const cloned = JSON.parse(JSON.stringify(config)) as ViewConfig;
+ if (!cloned.SECONDARY_CATEGORY_IDS && cloned.MAPEO_CATEGORY_IDS) {
+ cloned.SECONDARY_CATEGORY_IDS = cloned.MAPEO_CATEGORY_IDS;
+ }
+ delete cloned.MAPEO_CATEGORY_IDS;
+ return cloned;
};
/**
diff --git a/components/shared/DataFeature.vue b/components/shared/DataFeature.vue
index 04b6b961..a8ba9e48 100644
--- a/components/shared/DataFeature.vue
+++ b/components/shared/DataFeature.vue
@@ -16,7 +16,7 @@ const props = defineProps<{
featureGeojson?: Feature | AlertsData;
filePaths?: Array;
isAlert?: boolean;
- isMapeo?: boolean;
+ isSecondary?: boolean;
isAlertsDashboard?: boolean;
mediaBasePath?: string;
mediaBasePathAlerts?: string;
@@ -184,8 +184,8 @@ const exportRecordId = computed(() =>
{{
showCopied
? $t("copied")
- : isMapeo
- ? $t("copyMapeoLink")
+ : isSecondary
+ ? $t("copySecondaryLink")
: $t("copyLink")
}}
diff --git a/components/shared/MapLegend.vue b/components/shared/MapLegend.vue
index 662a63df..03304ebe 100644
--- a/components/shared/MapLegend.vue
+++ b/components/shared/MapLegend.vue
@@ -95,8 +95,8 @@ watch(
{{
- item.name === "Mapeo data"
- ? $t("mapeoData")
+ item.name === "Secondary data"
+ ? $t("secondaryData")
: item.name === "Most recent alerts"
? $t("mostRecentAlerts")
: item.name === "Previous alerts"
diff --git a/components/shared/ViewSidebar.vue b/components/shared/ViewSidebar.vue
index fac4b928..fae259f4 100644
--- a/components/shared/ViewSidebar.vue
+++ b/components/shared/ViewSidebar.vue
@@ -27,7 +27,7 @@ const props = defineProps<{
featureGeojson?: Feature | AlertsData;
filePaths?: Array;
isAlert?: boolean;
- isMapeo?: boolean;
+ isSecondary?: boolean;
isAlertsDashboard?: boolean;
localAlertsData?: Feature | AlertsData;
logoUrl?: string;
@@ -187,7 +187,7 @@ onBeforeUnmount(() => {
:feature-geojson="featureGeojson"
:file-paths="filePaths"
:is-alert="isAlert"
- :is-mapeo="isMapeo"
+ :is-secondary="isSecondary"
:is-alerts-dashboard="isAlertsDashboard"
:media-base-path="mediaBasePath"
:media-base-path-alerts="mediaBasePathAlerts"
diff --git a/composables/useFeatureSelection.ts b/composables/useFeatureSelection.ts
index 8c1ba41a..69e0ae03 100644
--- a/composables/useFeatureSelection.ts
+++ b/composables/useFeatureSelection.ts
@@ -14,7 +14,7 @@ export function useFeatureSelection(
localAlertsData: Ref,
showSidebar: Ref,
showIntroPanel: Ref,
- isMapeo: Ref,
+ isSecondary: Ref,
) {
// Feature selection state
const imageCaption = ref();
@@ -288,17 +288,18 @@ export function useFeatureSelection(
? featureObject.alertID
: feature.id;
- // Update URL with alertId or mapeoDocId; remove incidentId so address bar matches "copy link to alert"
+ // Update URL with alertId or secondaryDocId; remove incidentId so address bar matches "copy link to alert"
const query = { ...route.query };
delete query.alertId;
delete query.mapeoDocId;
+ delete query.secondaryDocId;
delete query.incidentId;
if (featureObject.alertID) {
query.alertId = featureObject.alertID;
- isMapeo.value = false;
+ isSecondary.value = false;
} else if (featureObject.id || featureObject._id) {
- query.mapeoDocId = featureObject.id || featureObject._id;
- isMapeo.value = true;
+ query.secondaryDocId = featureObject._id || featureObject.id;
+ isSecondary.value = true;
}
router.replace({ query });
@@ -420,7 +421,7 @@ export function useFeatureSelection(
} else {
isAlert.value = false;
- // If a Mapeo feature is selected, clear any cluster highlights
+ // If a secondary feature is selected, clear any cluster highlights
if (selectedClusterId.value !== null) {
selectedClusterId.value = null;
selectedClusterSource.value = null;
diff --git a/composables/useIncidents.ts b/composables/useIncidents.ts
index ac180baf..a9abf93f 100644
--- a/composables/useIncidents.ts
+++ b/composables/useIncidents.ts
@@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, ref } from "vue";
import type { RouteLocationNormalizedLoaded, Router } from "vue-router";
import mapboxgl from "mapbox-gl";
import type { Feature, Geometry } from "geojson";
+import { isSecondaryFeatureType } from "@/types";
import type {
AnnotatedCollection,
CollectionEntry,
@@ -34,7 +35,7 @@ type IncidentDetailsResponse = {
* @param router - Vue Router instance for programmatic navigation and query param management
* @param mapLegendLayerIds - Optional comma-separated layer ids selectable for incidents.
* @param primaryDatasetRef - Alerts dataset table returned by the view API.
- * @param secondaryDatasetRef - Optional Mapeo dataset table returned by the view API.
+ * @param secondaryDatasetRef - Optional secondary dataset table returned by the view API.
* @returns Object containing all incidents state and functions
*/
export const useIncidents = (
@@ -121,14 +122,15 @@ export const useIncidents = (
};
/**
- * True when a persisted incident entry refers to mapeo rows. {@link CollectionEntry.source_table}
+ * True when a persisted incident entry refers to secondary rows. {@link CollectionEntry.source_table}
* is the warehouse table name (often the view's secondary dataset, or `mapeo_data`)
*/
- const savedEntryIsMapeo = (entry: CollectionEntry): boolean => {
- const configuredMapeoTable = secondaryDatasetRef?.value;
+ const savedEntryIsSecondary = (entry: CollectionEntry): boolean => {
+ const configuredSecondaryTable = secondaryDatasetRef?.value;
return (
entry.source_table === "mapeo_data" ||
- (!!configuredMapeoTable && entry.source_table === configuredMapeoTable)
+ (!!configuredSecondaryTable &&
+ entry.source_table === configuredSecondaryTable)
);
};
@@ -429,10 +431,11 @@ export const useIncidents = (
clearSourceHighlighting();
highlightIncidentEntries(response.entries || []);
- // Add incidentId to URL; remove alert/mapeo params so address bar matches "copy link to incident"
+ // Add incidentId to URL; remove alert/secondary params so address bar matches "copy link to incident"
const query = { ...route.query };
delete query.alertId;
delete query.mapeoDocId;
+ delete query.secondaryDocId;
query.incidentId = incidentId;
router.replace({ query });
} catch (error) {
@@ -663,7 +666,7 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
"previous-alerts-point",
"previous-alerts-symbol",
"previous-alerts-centroids",
- "mapeo-data",
+ "secondary-data",
...getAdditionalSelectableLayerIds(),
].filter((layerId) => map.value!.getLayer(layerId));
@@ -690,11 +693,12 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
const sourceId = extractFeatureSourceId(feature);
if (!sourceId) return;
- const isMapeoLayer = layerId.startsWith("mapeo-data");
+ const isSecondaryLayer = layerId.startsWith("secondary-data");
const shouldHighlight = selectedSources.value.some((source) => {
if (source.source_id !== sourceId) return false;
- if (source.feature_type === "mapeo") return isMapeoLayer;
- return !isMapeoLayer;
+ if (isSecondaryFeatureType(source.feature_type))
+ return isSecondaryLayer;
+ return !isSecondaryLayer;
});
if (!shouldHighlight) return;
@@ -913,7 +917,7 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
"previous-alerts-centroids",
];
- const mapeoLayers = ["mapeo-data"];
+ const secondaryLayers = ["secondary-data"];
const additionalLayers = getAdditionalSelectableLayerIds();
@@ -934,30 +938,32 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
}> = [];
// First, get individual (non-clustered) features
- [...alertLayers, ...mapeoLayers, ...additionalLayers].forEach((layerId) => {
- try {
- if (map.value!.getLayer(layerId)) {
- const features = map.value!.queryRenderedFeatures(bbox, {
- layers: [layerId],
- });
- const validFeatures = features.filter(
- (f) =>
- !(f.properties as { cluster?: boolean; cluster_id?: number })
- ?.cluster &&
- (f.properties as { cluster_id?: number })?.cluster_id ===
- undefined &&
- !f.layer?.id?.includes("clusters") &&
- !f.layer?.id?.includes("cluster-count"),
- );
- allFeatures.push(...(validFeatures as typeof allFeatures));
- console.debug(
- `Layer ${layerId}: found ${validFeatures.length} individual features`,
- );
+ [...alertLayers, ...secondaryLayers, ...additionalLayers].forEach(
+ (layerId) => {
+ try {
+ if (map.value!.getLayer(layerId)) {
+ const features = map.value!.queryRenderedFeatures(bbox, {
+ layers: [layerId],
+ });
+ const validFeatures = features.filter(
+ (f) =>
+ !(f.properties as { cluster?: boolean; cluster_id?: number })
+ ?.cluster &&
+ (f.properties as { cluster_id?: number })?.cluster_id ===
+ undefined &&
+ !f.layer?.id?.includes("clusters") &&
+ !f.layer?.id?.includes("cluster-count"),
+ );
+ allFeatures.push(...(validFeatures as typeof allFeatures));
+ console.debug(
+ `Layer ${layerId}: found ${validFeatures.length} individual features`,
+ );
+ }
+ } catch (error) {
+ console.warn(`Error querying layer ${layerId}:`, error);
}
- } catch (error) {
- console.warn(`Error querying layer ${layerId}:`, error);
- }
- });
+ },
+ );
console.debug(
"Total individual features found in bounding box:",
@@ -985,7 +991,7 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
if (featureObject?.alertID) {
sourceId = featureObject.alertID;
} else if (featureObject?._id) {
- // Mapeo features use _id (migrated from id)
+ // Secondary features use _id (migrated from id)
sourceId = featureObject._id;
} else if (featureObject?.id) {
// Fallback for backward compatibility
@@ -1016,7 +1022,7 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
* Adds a source to the selected sources list for incident creation
* @param sourceTable - Warehouse table (alerts route table or secondary dataset)
* @param sourceId - The unique identifier from the source table
- * @param featureType - "alert" or "mapeo" so the server uses alert_id or _id when fetching the row
+ * @param featureType - "alert" or "secondary" so the server uses alert_id or _id when fetching the row
* @param notes - Optional notes about the source
*/
const addSourceToSelection = (
@@ -1070,14 +1076,14 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
*
* Source table determination logic:
* - For alert layers (containing "most-recent-alerts" or "previous-alerts"): Uses the primary dataset; feature_type "alert".
- * - For Mapeo layers (layerId.startsWith("mapeo-data")): Uses the secondary dataset; feature_type "mapeo".
+ * - For secondary layers (layerId.startsWith("secondary-data")): Uses the secondary dataset; feature_type "secondary".
*
* Source ID extraction:
* - For alerts: Uses feature.properties.alertID
- * - For Mapeo: Uses feature.properties._id (with fallback to feature.properties.id for backward compatibility)
+ * - For secondary: Uses feature.properties._id (with fallback to feature.properties.id for backward compatibility)
*
* @param feature - The map feature to select/deselect
- * @param layerId - The layer ID the feature belongs to (e.g., "most-recent-alerts-polygon", "mapeo-data")
+ * @param layerId - The layer ID the feature belongs to (e.g., "most-recent-alerts-polygon", "secondary-data")
*/
const handleMultiSelectFeature = (feature: Feature, layerId: string) => {
if (!feature.properties) return;
@@ -1094,9 +1100,9 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
) {
sourceTable = tableName || "";
featureType = "alert";
- } else if (layerId.startsWith("mapeo-data")) {
+ } else if (layerId.startsWith("secondary-data")) {
sourceTable = secondaryDatasetRef?.value ?? "";
- featureType = "mapeo";
+ featureType = "secondary";
} else if (isAdditionalSelectableLayer(layerId)) {
sourceTable = tableName || "";
featureType = "alert";
@@ -1105,7 +1111,7 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
if (feature.properties.alertID) {
sourceId = feature.properties.alertID;
} else if (feature.properties._id) {
- // Mapeo features use _id as primary key (migrated from id)
+ // Secondary features use _id as primary key (migrated from id)
// After migration 0002_standardize_mapeo_data_primary_key.sql, the mapeo_data table
// uses _id as its primary key instead of id. However, we maintain backward compatibility
// by checking for id as a fallback because migrations have not yet been run on all partner
@@ -1581,7 +1587,7 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
// If the entry refers to a different alerts table than the current route,
// the map won't have that data loaded; still try best-effort highlighting.
if (
- !savedEntryIsMapeo(entry) &&
+ !savedEntryIsSecondary(entry) &&
currentAlertsTable &&
entry.source_table !== currentAlertsTable
) {
@@ -1592,11 +1598,13 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
);
}
- const candidateSources = savedEntryIsMapeo(entry)
- ? ["mapeo-data"]
+ const candidateSources = savedEntryIsSecondary(entry)
+ ? ["secondary-data"]
: alertLayers;
- const filter: mapboxgl.ExpressionSpecification = savedEntryIsMapeo(entry)
+ const filter: mapboxgl.ExpressionSpecification = savedEntryIsSecondary(
+ entry,
+ )
? [
"any",
["==", ["get", "_id"], entry.source_id],
@@ -1625,7 +1633,7 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
if (isCluster) {
// If it's a cluster, highlight the cluster
// For alert entries, we need to find which cluster contains this alertID
- if (!savedEntryIsMapeo(entry) && entry.source_id) {
+ if (!savedEntryIsSecondary(entry) && entry.source_id) {
// Determine the centroids source for cluster checking
const centroidsSource = sourceId.includes("most-recent")
? "most-recent-alerts-centroids"
@@ -1648,7 +1656,7 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
// Also check if this feature is part of a cluster at current zoom
// (it might be de-clustered when zoomed in, but we still want to highlight the cluster if zoomed out)
- if (!savedEntryIsMapeo(entry) && entry.source_id) {
+ if (!savedEntryIsSecondary(entry) && entry.source_id) {
const centroidsSource = sourceId.includes("most-recent")
? "most-recent-alerts-centroids"
: sourceId.includes("previous")
@@ -1678,7 +1686,7 @@ const SOURCE_ID_KEYS = ['alertID', '_id', 'source_id', 'sourceId'] as const;
// If we didn't find the feature in any source, it might be clustered
// Try checking clusters directly
- if (!found && !savedEntryIsMapeo(entry) && entry.source_id) {
+ if (!found && !savedEntryIsSecondary(entry) && entry.source_id) {
const centroidsSources = [
"most-recent-alerts-centroids",
"previous-alerts-centroids",
diff --git a/docs/config.md b/docs/config.md
index c7a959a4..fb5e2467 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -96,13 +96,15 @@ List the exact column names (`UNWANTED_COLUMNS`) and/or columns containing speci
## Alerts configuration
-#### `MAPEO_CATEGORY_IDS` (optional, for Alerts view)
+#### Secondary dataset (optional, for Alerts / Map views)
-For showing Mapeo data on the Alerts Dashboard, provide a comma-separated list of `categoryId` values that you want to show.
+Choose any geospatial companion table (columns `g__type` and `g__coordinates`) as the view's secondary dataset. The config UI only lists geospatial tables. Existing views that used a Mapeo table as the companion continue to work.
-#### `MAPEO_TABLE` (optional, for Alerts view)
+#### `SECONDARY_CATEGORY_IDS` (optional, for Alerts view)
-For showing Mapeo data on the Alerts Dashboard, provide the name of the Mapeo database table.
+Optional comma-separated allowlist of category values to show from the secondary dataset on the Alerts map. Matching looks at any column whose name includes `category` (for example Mapeo's `categoryId`). If omitted, all geospatial rows from the secondary dataset are shown.
+
+> Legacy configs may still store `MAPEO_CATEGORY_IDS`; the app and migration `0010_rename_mapeo_category_ids` map that key to `SECONDARY_CATEGORY_IDS`.
## Other configuration
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 5ecb9c2e..bf4d55de 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -51,7 +51,6 @@
"copyConfigDescription": "Select a view to copy its configuration values into the current form. You can modify the values before saving.",
"copyConfigFromDatasetView": "Copy Config from Another View",
"copyLink": "Copy link to alert",
- "copyMapeoLink": "Copy link to Mapeo observation",
"created": "Created",
"createViewTypeDescription": "Choose a view type to continue. You can optionally pick a primary dataset now, or on the next page.",
"data": "data",
@@ -161,7 +160,6 @@
"incidents.responsibleParty": "Responsible Party",
"incidents.savedIncidents": "Saved Incidents",
"incidents.selectedAlertsCount": "Alerts selected: {count}",
- "incidents.selectedMapeoCount": "Mapeo items selected: {count}",
"incidents.selectedSources": "Selected Sources",
"incidents.selectFeaturesFirst": "Select features first to create an incident",
"incidents.selectType": "Select type",
@@ -189,8 +187,6 @@
"mapboxStreets": "Mapbox Streets",
"mapboxStyle": "Mapbox Style",
"mapboxZoom": "Zoom level (0 to 22)",
- "mapeoCategoryIds": "Mapeo Category IDs to show on the alerts map",
- "mapeoData": "Mapeo data",
"mapLegend": "Map Legend",
"mapLegendLayerIds": "Layer IDs to show in the map legend",
"media": "Media",
@@ -303,5 +299,9 @@
"visibilityPublicDescription": "Anyone with the link can view.",
"visibilityRequired": "Please select a visibility level for this dataset.",
"yourAccessIsPending": "Your access is pending. Please contact a Guardian Connector administrator for account approval.",
- "yourMapboxStyleDefault": "Mapbox - your style (default)"
+ "yourMapboxStyleDefault": "Mapbox - your style (default)",
+ "copySecondaryLink": "Copy link to secondary observation",
+ "secondaryCategoryIds": "Category IDs to show from the secondary dataset on the alerts map",
+ "secondaryData": "Secondary data",
+ "incidents.selectedSecondaryCount": "Secondary items selected: {count}"
}
diff --git a/i18n/locales/es.json b/i18n/locales/es.json
index d48ef127..bf8b4f20 100644
--- a/i18n/locales/es.json
+++ b/i18n/locales/es.json
@@ -48,7 +48,6 @@
"copyConfigDescription": "Seleccione una vista para copiar sus valores de configuración en el formulario actual. Puede modificar los valores antes de guardar.",
"copyConfigFromDatasetView": "Copiar configuración de otra vista",
"copyLink": "Copiar enlace a la alerta",
- "copyMapeoLink": "Copiar enlace a la observación de Mapeo",
"created": "Creado",
"createViewTypeDescription": "Elija un tipo de vista para continuar. Puede seleccionar un conjunto de datos principal ahora o en la página siguiente.",
"data": "datos",
@@ -157,7 +156,6 @@
"incidents.responsibleParty": "Parte Responsable",
"incidents.savedIncidents": "Incidentes Guardados",
"incidents.selectedAlertsCount": "Alertas seleccionadas: {count}",
- "incidents.selectedMapeoCount": "Elementos de Mapeo seleccionados: {count}",
"incidents.selectedSources": "Fuentes Seleccionadas",
"incidents.selectFeaturesFirst": "Seleccione características primero para crear un incidente",
"incidents.selectType": "Seleccionar tipo",
@@ -185,8 +183,6 @@
"mapboxStreets": "Mapbox Streets",
"mapboxStyle": "Estilo de Mapbox",
"mapboxZoom": "Nivel de zoom (0 a 22)",
- "mapeoCategoryIds": "IDs de categorías de Mapeo para mostrar en el mapa de alertas",
- "mapeoData": "Datos de Mapeo",
"mapLegend": "Leyenda del mapa",
"mapLegendLayerIds": "IDs de capas para mostrar en la leyenda del mapa",
"media": "Medios",
@@ -299,5 +295,9 @@
"visibilityPublicDescription": "Cualquiera con el enlace puede ver.",
"visibilityRequired": "Por favor, seleccione un nivel de visibilidad para este conjunto de datos.",
"yourAccessIsPending": "Su acceso está pendiente. Ponte en contacto con un administrador de Guardian Connector para la aprobación de su cuenta.",
- "yourMapboxStyleDefault": "Mapbox - su estilo (predeterminado)"
+ "yourMapboxStyleDefault": "Mapbox - su estilo (predeterminado)",
+ "copySecondaryLink": "Copiar enlace a la observación secundaria",
+ "secondaryCategoryIds": "IDs de categorías del conjunto de datos secundario para mostrar en el mapa de alertas",
+ "secondaryData": "Datos secundarios",
+ "incidents.selectedSecondaryCount": "Elementos secundarios seleccionados: {count}"
}
diff --git a/i18n/locales/nl.json b/i18n/locales/nl.json
index 8f702cc0..333fe10c 100644
--- a/i18n/locales/nl.json
+++ b/i18n/locales/nl.json
@@ -49,7 +49,6 @@
"copyConfigDescription": "Selecteer een weergave om de configuratiewaarden in het huidige formulier te kopiëren. U kunt de waarden aanpassen voordat u opslaat.",
"copyConfigFromDatasetView": "Configuratie kopiëren van een andere weergave",
"copyLink": "Kopieer link naar alert",
- "copyMapeoLink": "Kopieer link naar Mapeo observatie",
"created": "Gemaakt",
"createViewTypeDescription": "Kies een weergavetype om verder te gaan. U kunt nu optioneel een primaire dataset kiezen, of op de volgende pagina.",
"data": "gegevens",
@@ -158,7 +157,6 @@
"incidents.responsibleParty": "Verantwoordelijke Partij",
"incidents.savedIncidents": "Opgeslagen Incidenten",
"incidents.selectedAlertsCount": "Geselecteerde alerts: {count}",
- "incidents.selectedMapeoCount": "Geselecteerde Mapeo-items: {count}",
"incidents.selectedSources": "Geselecteerde Bronnen",
"incidents.selectFeaturesFirst": "Selecteer eerst objecten om een incident te maken",
"incidents.selectType": "Selecteer type",
@@ -186,8 +184,6 @@
"mapboxStreets": "Mapbox Streets",
"mapboxStyle": "Mapbox stijl",
"mapboxZoom": "Zoomniveau (0 tot 22)",
- "mapeoCategoryIds": "Mapeo categorie-ID's om op de alertkaart te tonen",
- "mapeoData": "Mapeo data",
"mapLegend": "Kaartlegenda",
"mapLegendLayerIds": "Laag-ID's om in de kaartlegenda te tonen",
"media": "Media",
@@ -300,5 +296,9 @@
"visibilityPublicDescription": "Iedereen met de link kan bekijken.",
"visibilityRequired": "Selecteer een zichtbaarheidsniveau voor deze dataset.",
"yourAccessIsPending": "Uw toegang is in behandeling. Neem contact op met een Guardian Connector-beheerder voor goedkeuring van je account.",
- "yourMapboxStyleDefault": "Mapbox - uw stijl (standaard)"
+ "yourMapboxStyleDefault": "Mapbox - uw stijl (standaard)",
+ "copySecondaryLink": "Kopieer link naar secundaire observatie",
+ "secondaryCategoryIds": "Categorie-ID's van de secundaire dataset om op de alertkaart te tonen",
+ "secondaryData": "Secundaire data",
+ "incidents.selectedSecondaryCount": "Geselecteerde secundaire items: {count}"
}
diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json
index e6f8b8bd..e6b33aae 100644
--- a/i18n/locales/pt.json
+++ b/i18n/locales/pt.json
@@ -48,7 +48,6 @@
"copyConfigDescription": "Selecione uma visualização para copiar seus valores de configuração no formulário atual. Você pode modificar os valores antes de salvar.",
"copyConfigFromDatasetView": "Copiar configuração de outra visualização",
"copyLink": "Copiar link para alerta",
- "copyMapeoLink": "Copiar link para observação do Mapeo",
"created": "Criado",
"createViewTypeDescription": "Escolha um tipo de visualização para continuar. Você pode selecionar um conjunto de dados principal agora ou na próxima página.",
"data": "dados",
@@ -157,7 +156,6 @@
"incidents.responsibleParty": "Parte Responsável",
"incidents.savedIncidents": "Incidentes Salvos",
"incidents.selectedAlertsCount": "Alertas selecionados: {count}",
- "incidents.selectedMapeoCount": "Itens do Mapeo selecionados: {count}",
"incidents.selectedSources": "Fontes Selecionadas",
"incidents.selectFeaturesFirst": "Selecione características primeiro para criar um incidente",
"incidents.selectType": "Selecionar tipo",
@@ -185,8 +183,6 @@
"mapboxStreets": "Mapbox Streets",
"mapboxStyle": "Estilo do Mapbox",
"mapboxZoom": "Nível de zoom (0 a 22)",
- "mapeoCategoryIds": "IDs de categorias do Mapeo para mostrar no mapa de alertas",
- "mapeoData": "Dados do Mapeo",
"mapLegend": "Legenda do mapa",
"mapLegendLayerIds": "IDs das camadas para mostrar na legenda do mapa",
"media": "Mídia",
@@ -299,5 +295,9 @@
"visibilityPublicDescription": "Qualquer pessoa com o link pode ver.",
"visibilityRequired": "Por favor, selecione um nível de visibilidade para este conjunto de dados.",
"yourAccessIsPending": "Seu acesso está pendente. Entre em contato com um administrador do Guardian Connector para aprovação da conta.",
- "yourMapboxStyleDefault": "Mapbox - seu estilo (padrão)"
+ "yourMapboxStyleDefault": "Mapbox - seu estilo (padrão)",
+ "copySecondaryLink": "Copiar link para observação secundária",
+ "secondaryCategoryIds": "IDs de categorias do conjunto de dados secundário para mostrar no mapa de alertas",
+ "secondaryData": "Dados secundários",
+ "incidents.selectedSecondaryCount": "Itens secundários selecionados: {count}"
}
diff --git a/i18n/locales/sw.json b/i18n/locales/sw.json
index 57458322..c9709685 100644
--- a/i18n/locales/sw.json
+++ b/i18n/locales/sw.json
@@ -51,7 +51,6 @@
"copyConfigDescription": "Chagua mtazamo ili kunakili thamani za usanidi wake kwenye fomu ya sasa. Unaweza kubadilisha thamani kabla ya kuhifadhi.",
"copyConfigFromDatasetView": "Nakili usanidi kutoka mtazamo mwingine",
"copyLink": "Nakili kiungo cha tahadhari",
- "copyMapeoLink": "Nakili kiungo cha uchunguzi wa Mapeo",
"created": "Imeundwa",
"createViewTypeDescription": "Chagua aina ya mwonekano ili kuendelea. Unaweza kuchagua seti kuu ya data sasa, au kwenye ukurasa unaofuata.",
"data": "data",
@@ -161,7 +160,6 @@
"incidents.responsibleParty": "Mhusika wa Uwajibikaji",
"incidents.savedIncidents": "Matukio Yaliyohifadhiwa",
"incidents.selectedAlertsCount": "Tahadhari zilizochaguliwa: {count}",
- "incidents.selectedMapeoCount": "Vipengele vya Mapeo vilivyochaguliwa: {count}",
"incidents.selectedSources": "Vyanzo Vilivyochaguliwa",
"incidents.selectFeaturesFirst": "Chagua vipengele kwanza ili kuunda tukio",
"incidents.selectType": "Chagua aina",
@@ -189,8 +187,6 @@
"mapboxStreets": "Mapbox Streets",
"mapboxStyle": "Mtindo wa Mapbox",
"mapboxZoom": "Kiwango cha kuvuta (0 hadi 22)",
- "mapeoCategoryIds": "Vitambulisho vya jamii za Mapeo vya kuonyesha kwenye ramani ya tahadhari",
- "mapeoData": "Data ya Mapeo",
"mapLegend": "Hadithi ya Ramani",
"mapLegendLayerIds": "Vitambulisho vya safu vya kuonyesha katika hadithi ya ramani",
"media": "Midia",
@@ -303,5 +299,9 @@
"visibilityPublicDescription": "Mtu yeyote aliye na kiungo anaweza kuona.",
"visibilityRequired": "Tafadhali chagua kiwango cha mwonekano kwa seti hii ya data.",
"yourAccessIsPending": "Ufikiaji wako unasubiri. Tafadhali wasiliana na msimamizi wa Guardian Connector kwa idhini ya akaunti.",
- "yourMapboxStyleDefault": "Mapbox - mtindo wako (chaguo-msingi)"
+ "yourMapboxStyleDefault": "Mapbox - mtindo wako (chaguo-msingi)",
+ "copySecondaryLink": "Nakili kiungo cha uchunguzi wa seti ya pili",
+ "secondaryCategoryIds": "Vitambulisho vya jamii vya seti ya pili ya data vya kuonyesha kwenye ramani ya tahadhari",
+ "secondaryData": "Data ya seti ya pili",
+ "incidents.selectedSecondaryCount": "Vipengele vya seti ya pili vilivyochaguliwa: {count}"
}
diff --git a/i18n/locales/th.json b/i18n/locales/th.json
index 26bdfd0d..f36d1861 100644
--- a/i18n/locales/th.json
+++ b/i18n/locales/th.json
@@ -51,7 +51,6 @@
"copyConfigDescription": "เลือกมุมมองเพื่อคัดลอกค่าการกำหนดค่าไปยังฟอร์มปัจจุบัน คุณแก้ไขค่าก่อนบันทึกได้",
"copyConfigFromDatasetView": "คัดลอกการกำหนดค่าจากมุมมองอื่น",
"copyLink": "คัดลอกลิงก์ไปยังการแจ้งเตือน",
- "copyMapeoLink": "คัดลอกลิงก์ไปยังการสังเกตการณ์ Mapeo",
"created": "สร้างเมื่อ",
"createViewTypeDescription": "เลือกประเภทมุมมองเพื่อดำเนินการต่อ คุณสามารถเลือกชุดข้อมูลหลักได้ตอนนี้ หรือในหน้าถัดไป",
"data": "ข้อมูล",
@@ -161,7 +160,6 @@
"incidents.responsibleParty": "ฝ่ายที่รับผิดชอบ",
"incidents.savedIncidents": "เหตุการณ์ที่บันทึกไว้",
"incidents.selectedAlertsCount": "เลือกการแจ้งเตือน: {count}",
- "incidents.selectedMapeoCount": "เลือกรายการ Mapeo: {count}",
"incidents.selectedSources": "แหล่งที่เลือก",
"incidents.selectFeaturesFirst": "เลือกฟีเจอร์ก่อนเพื่อสร้างเหตุการณ์",
"incidents.selectType": "เลือกประเภท",
@@ -189,8 +187,6 @@
"mapboxStreets": "Mapbox Streets",
"mapboxStyle": "Mapbox Style",
"mapboxZoom": "ระดับการซูม (0 ถึง 22)",
- "mapeoCategoryIds": "Mapeo Category IDs ที่จะแสดงบนแผนที่แจ้งเตือน",
- "mapeoData": "ข้อมูล Mapeo",
"mapLegend": "คำอธิบายสัญลักษณ์แผนที่",
"mapLegendLayerIds": "รหัสชั้นข้อมูลที่แสดงในคำอธิบายสัญลักษณ์",
"media": "สื่อ",
@@ -303,5 +299,9 @@
"visibilityPublicDescription": "ทุกคนที่มีลิงก์ดูได้",
"visibilityRequired": "โปรดเลือกระดับการมองเห็นสำหรับชุดข้อมูลนี้",
"yourAccessIsPending": "การเข้าถึงของคุณอยู่ระหว่างรออนุมัติ โปรดติดต่อผู้ดูแลระบบ Guardian Connector เพื่อขออนุมัติบัญชี",
- "yourMapboxStyleDefault": "Mapbox — สไตล์ของคุณ (ค่าเริ่มต้น)"
+ "yourMapboxStyleDefault": "Mapbox — สไตล์ของคุณ (ค่าเริ่มต้น)",
+ "copySecondaryLink": "คัดลอกลิงก์ไปยังการสังเกตการณ์ชุดข้อมูลรอง",
+ "secondaryCategoryIds": "Category IDs จากชุดข้อมูลรองที่จะแสดงบนแผนที่แจ้งเตือน",
+ "secondaryData": "ข้อมูลรอง",
+ "incidents.selectedSecondaryCount": "เลือกรายการรอง: {count}"
}
diff --git a/pages/alerts/[tablename].vue b/pages/alerts/[tablename].vue
index c04659f5..e576d363 100644
--- a/pages/alerts/[tablename].vue
+++ b/pages/alerts/[tablename].vue
@@ -39,7 +39,7 @@ const mapboxBasemaps = ref([]);
const mapboxZoom = ref(0);
const mapbox3d = ref(false);
const mapbox3dTerrainExaggeration = ref(0);
-const mapeoData = ref();
+const secondaryData = ref();
const primaryDataset = ref(table);
const secondaryDataset = ref(null);
const mediaBasePath = ref();
@@ -70,7 +70,7 @@ if (data.value && !error.value) {
mapboxZoom.value = data.value.mapboxZoom;
mapbox3d.value = data.value.mapbox3d;
mapbox3dTerrainExaggeration.value = data.value.mapbox3dTerrainExaggeration;
- mapeoData.value = data.value.mapeoData;
+ secondaryData.value = data.value.secondaryData;
primaryDataset.value = data.value.primary_dataset;
secondaryDataset.value = data.value.secondary_dataset;
mediaBasePath.value = data.value.mediaBasePath;
@@ -124,7 +124,7 @@ useHead({
:mapbox-zoom="mapboxZoom"
:mapbox3d="mapbox3d"
:mapbox3d-terrain-exaggeration="mapbox3dTerrainExaggeration"
- :mapeo-data="mapeoData"
+ :secondary-data="secondaryData"
:primary-dataset="primaryDataset"
:secondary-dataset="secondaryDataset"
:media-base-path="mediaBasePath"
diff --git a/pages/config/[dataset].vue b/pages/config/[dataset].vue
index 148dd15e..6415f82c 100644
--- a/pages/config/[dataset].vue
+++ b/pages/config/[dataset].vue
@@ -38,6 +38,7 @@ const editedViewType = ref(undefined);
const { data, error, refresh } = await useFetch<{
views: ViewConfigRow[];
availableTables: string[];
+ availableGeospatialTables?: string[];
}>("/api/config");
if (data.value && !error.value) {
@@ -66,6 +67,9 @@ if (data.value && !error.value) {
const resolvedViewType = computed(() => viewType.value ?? editedViewType.value);
const availableTables = computed(() => data.value?.availableTables ?? []);
+const availableGeospatialTables = computed(
+ () => data.value?.availableGeospatialTables ?? availableTables.value,
+);
const showsSecondaryDataset = computed(() =>
supportsSecondaryDataset(resolvedViewType.value),
);
@@ -284,7 +288,7 @@ definePageMeta({ layout: "explorer" });
id="edit-view-secondaryDataset-select"
:model-value="secondaryDataset"
:label="$t('secondaryDatasetOptional')"
- :options="availableTables"
+ :options="availableGeospatialTables"
:placeholder="$t('selectSecondaryDataset')"
test-id="edit-secondary-dataset-select"
:exclude-value="dataset"
diff --git a/pages/config/new/[view_type].vue b/pages/config/new/[view_type].vue
index 78c76af0..d294e7ef 100644
--- a/pages/config/new/[view_type].vue
+++ b/pages/config/new/[view_type].vue
@@ -42,9 +42,13 @@ const primaryDataset = ref(
const { data, error, refresh } = await useFetch<{
views: ViewConfigRow[];
availableTables: string[];
+ availableGeospatialTables?: string[];
}>("/api/config");
const availableTables = computed(() => data.value?.availableTables ?? []);
+const availableGeospatialTables = computed(
+ () => data.value?.availableGeospatialTables ?? availableTables.value,
+);
const viewRows = computed(() => data.value?.views ?? []);
const viewConfig = ref({});
@@ -204,7 +208,7 @@ definePageMeta({ layout: "explorer" });
id="create-view-secondaryDataset-select"
:model-value="secondaryDataset"
:label="$t('secondaryDatasetOptional')"
- :options="availableTables"
+ :options="availableGeospatialTables"
:placeholder="$t('selectSecondaryDataset')"
test-id="secondary-dataset-select"
:exclude-value="primaryDataset"
diff --git a/server/annotatedCollections/index.ts b/server/annotatedCollections/index.ts
index 8bed6b29..cf551ca8 100644
--- a/server/annotatedCollections/index.ts
+++ b/server/annotatedCollections/index.ts
@@ -16,7 +16,7 @@ import {
* Creates a new annotated collection with optional incident data and collection entries
* @param collection - The annotated collection data (without id, created_at, updated_at)
* @param incidentData - Optional incident-specific data if collection_type is "incident"
- * @param entries - Optional array of collection entries to add. Each entry must include feature_type ("alert" | "mapeo") so the server uses alert_id for alerts and _id for mapeo.
+ * @param entries - Optional array of collection entries to add. Each entry must include feature_type ("alert" | "secondary", or legacy "mapeo") so the server uses alert_id for alerts and _id for secondary/mapeo rows.
* @returns Promise - The created annotated collection
*/
export const createAnnotatedCollection = async (
@@ -254,7 +254,7 @@ export const updateAnnotatedCollection = async (
/**
* Adds collection entries to an existing annotated collection
* @param collectionId - The annotated collection ID to add entries to
- * @param entries - Array of entries to add; each must include feature_type ("alert" | "mapeo") so the server uses alert_id or _id
+ * @param entries - Array of entries to add; each must include feature_type ("alert" | "secondary", or legacy "mapeo") so the server uses alert_id or _id
* @param addedBy - User ID who is adding the collection entries
* @returns Promise - The added collection entries
*/
diff --git a/server/api/[table]/alerts.ts b/server/api/[table]/alerts.ts
index 81c8d7a1..850317cd 100644
--- a/server/api/[table]/alerts.ts
+++ b/server/api/[table]/alerts.ts
@@ -21,7 +21,12 @@ import { buildRequiredAlertsProjection } from "@/server/utils/alertsProjection";
import { parseAndValidateLimit, getTableParam } from "@/server/utils/dbHelpers";
import type { H3Event } from "h3";
-import type { AllowedFileExtensions, DataEntry, AlertsMetadata } from "@/types";
+import type {
+ AllowedFileExtensions,
+ DataEntry,
+ AlertsMetadata,
+ ViewConfig,
+} from "@/types";
import type { FeatureCollection } from "geojson";
const ALERTS_MAIN_PROJECTION = [
@@ -48,6 +53,12 @@ const REQUIRED_ALERTS_MAIN_COLUMNS = [
"g__coordinates",
];
+/** Prefer SECONDARY_CATEGORY_IDS; fall back to legacy MAPEO_CATEGORY_IDS. */
+const resolveSecondaryCategoryIds = (
+ tableConfig: ViewConfig,
+): string | undefined =>
+ tableConfig.SECONDARY_CATEGORY_IDS || tableConfig.MAPEO_CATEGORY_IDS;
+
export default defineEventHandler(async (event: H3Event) => {
const table = getTableParam(event);
const limit = parseAndValidateLimit(event);
@@ -86,14 +97,14 @@ export default defineEventHandler(async (event: H3Event) => {
(columnName) => availableMetadataColumns.includes(columnName),
);
- const mapeoCategoryIds = tableConfig.MAPEO_CATEGORY_IDS;
- const shouldFetchMapeoData = Boolean(secondaryTable && mapeoCategoryIds);
- const mapeoMainColumns = shouldFetchMapeoData
+ const secondaryCategoryIds = resolveSecondaryCategoryIds(tableConfig);
+ const shouldFetchSecondaryData = Boolean(secondaryTable);
+ const secondaryMainColumns = shouldFetchSecondaryData
? await fetchTableSqlColumns(secondaryTable!)
: [];
const { primaryData, secondaryData } = await fetchViewData(primaryTable, {
- secondaryTable: shouldFetchMapeoData ? secondaryTable : null,
+ secondaryTable: shouldFetchSecondaryData ? secondaryTable : null,
primaryOptions: {
limit,
mainColumns: alertsMainProjection,
@@ -102,7 +113,7 @@ export default defineEventHandler(async (event: H3Event) => {
},
secondaryOptions: {
limit,
- mainColumns: mapeoMainColumns,
+ mainColumns: secondaryMainColumns,
includeColumnsData: true,
},
});
@@ -131,40 +142,46 @@ export default defineEventHandler(async (event: H3Event) => {
),
};
- const mapeoTable = secondaryTable;
-
- let mapeoData: FeatureCollection | null = null;
+ let secondaryGeojson: FeatureCollection | null = null;
- if (secondaryData && mapeoCategoryIds) {
+ if (secondaryData) {
// Filter data to remove unwanted columns and substrings
- const filteredMapeoData = filterUnwantedKeys(
+ let filteredSecondaryData = filterUnwantedKeys(
secondaryData.mainData,
secondaryData.columnsData,
tableConfig.UNWANTED_COLUMNS,
tableConfig.UNWANTED_SUBSTRINGS,
);
- // Filter Mapeo data to only show data where category matches any values in mapeoCategoryIds (a comma-separated string of values)
- const filteredMapeoDataByCategory = filteredMapeoData.filter(
- (row: DataEntry) => {
- return Object.keys(row).some(
- (key) =>
- key.includes("category") &&
- mapeoCategoryIds.split(",").includes(row[key]),
- );
+ // Optional category allowlist: any column whose name includes "category"
+ if (secondaryCategoryIds) {
+ const allowed = secondaryCategoryIds.split(",");
+ filteredSecondaryData = filteredSecondaryData.filter(
+ (row: DataEntry) => {
+ return Object.keys(row).some(
+ (key) =>
+ key.includes("category") &&
+ allowed.includes(row[key] as string),
+ );
+ },
+ );
+ }
+
+ // Filter only data with valid geofields
+ const filteredSecondaryGeoData = filterGeoData(filteredSecondaryData);
+
+ secondaryGeojson = buildMinimalFeatureCollection(
+ filteredSecondaryGeoData,
+ {
+ idField: "_id",
+ includeAllProperties: true,
+ filterColumn: tableConfig.FRONT_END_FILTER_COLUMN,
},
);
- // Filter only data with valid geofields
- const filteredMapeoGeoData = filterGeoData(filteredMapeoDataByCategory);
-
- // Process geodata
- mapeoData = buildMinimalFeatureCollection(filteredMapeoGeoData, {
- idField: "_id",
- includeAllProperties: true,
- filterColumn: tableConfig.FRONT_END_FILTER_COLUMN,
- isMapeoData: true,
- });
+ if (secondaryGeojson.features.length === 0) {
+ secondaryGeojson = null;
+ }
}
// Prepare statistics data for the alerts view
@@ -192,8 +209,7 @@ export default defineEventHandler(async (event: H3Event) => {
mapboxStyle: defaultMapboxStyle,
mapboxBasemaps: basemaps,
mapboxZoom: Number(tableConfig.MAPBOX_ZOOM),
- mapeoTable,
- mapeoData,
+ secondaryData: secondaryGeojson,
mediaBasePath: tableConfig.MEDIA_BASE_PATH,
mediaBasePathAlerts: tableConfig.MEDIA_BASE_PATH_ALERTS,
planetApiKey: tableConfig.PLANET_API_KEY,
diff --git a/server/api/config/index.get.ts b/server/api/config/index.get.ts
index 375c4c18..3a81736e 100644
--- a/server/api/config/index.get.ts
+++ b/server/api/config/index.get.ts
@@ -1,5 +1,8 @@
import { fetchViewConfigRows } from "@/server/database/dbOperations";
-import { getFilteredTableNames } from "@/server/utils";
+import {
+ getFilteredTableNames,
+ getGeospatialTableNames,
+} from "@/server/utils";
import { validateUserSession } from "@/utils/accessControls";
import type { H3Event } from "h3";
@@ -12,8 +15,15 @@ export default defineEventHandler(async (event: H3Event) => {
// All warehouse tables (minus metadata/PostGIS). Per-type uniqueness is
// enforced on the create form via GET /api/config/:table, not by hiding
// datasets that already have some other view type.
- const tableNames = await getFilteredTableNames();
- return { views: viewRows, availableTables: tableNames };
+ const [tableNames, geospatialTables] = await Promise.all([
+ getFilteredTableNames(),
+ getGeospatialTableNames(),
+ ]);
+ return {
+ views: viewRows,
+ availableTables: tableNames,
+ availableGeospatialTables: geospatialTables,
+ };
} catch (error) {
if (error instanceof Error) {
console.error("Error fetching config on API side:", error.message);
diff --git a/server/database/migrations/0010_rename_mapeo_category_ids.sql b/server/database/migrations/0010_rename_mapeo_category_ids.sql
new file mode 100644
index 00000000..f8ed0926
--- /dev/null
+++ b/server/database/migrations/0010_rename_mapeo_category_ids.sql
@@ -0,0 +1,20 @@
+-- Rename MAPEO_CATEGORY_IDS → SECONDARY_CATEGORY_IDS in view_config JSON.
+-- Prefer an existing SECONDARY_CATEGORY_IDS value if both keys are present.
+UPDATE views
+SET view_config = (
+ CASE
+ WHEN view_config::jsonb ? 'MAPEO_CATEGORY_IDS'
+ AND NOT (view_config::jsonb ? 'SECONDARY_CATEGORY_IDS')
+ THEN (
+ (view_config::jsonb - 'MAPEO_CATEGORY_IDS')
+ || jsonb_build_object(
+ 'SECONDARY_CATEGORY_IDS',
+ view_config::jsonb -> 'MAPEO_CATEGORY_IDS'
+ )
+ )
+ WHEN view_config::jsonb ? 'MAPEO_CATEGORY_IDS'
+ THEN (view_config::jsonb - 'MAPEO_CATEGORY_IDS')
+ ELSE view_config::jsonb
+ END
+)::text
+WHERE view_config::jsonb ? 'MAPEO_CATEGORY_IDS';
diff --git a/server/database/migrations/meta/0010_snapshot.json b/server/database/migrations/meta/0010_snapshot.json
new file mode 100644
index 00000000..2220c7df
--- /dev/null
+++ b/server/database/migrations/meta/0010_snapshot.json
@@ -0,0 +1,368 @@
+{
+ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "prevId": "801cd1f3-de85-425e-8fe1-57f5ab8a842c",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.views": {
+ "name": "views",
+ "schema": "",
+ "columns": {
+ "view_id": {
+ "name": "view_id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "name": "views_view_id_seq",
+ "increment": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "startWith": "1",
+ "cache": "1",
+ "cycle": false,
+ "schema": "public",
+ "type": "byDefault"
+ }
+ },
+ "view_name": {
+ "name": "view_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "view_type": {
+ "name": "view_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "primary_dataset": {
+ "name": "primary_dataset",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secondary_dataset": {
+ "name": "secondary_dataset",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "view_config": {
+ "name": "view_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "views_view_id_idx": {
+ "name": "views_view_id_idx",
+ "columns": [
+ {
+ "expression": "view_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "views_view_type_primary_dataset_unique": {
+ "name": "views_view_type_primary_dataset_unique",
+ "columns": [
+ "view_type",
+ "primary_dataset"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.public_views": {
+ "name": "public_views",
+ "schema": "",
+ "columns": {
+ "table_name": {
+ "name": "table_name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.annotated_collections": {
+ "name": "annotated_collections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "collection_type": {
+ "name": "collection_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'::jsonb"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.collection_entries": {
+ "name": "collection_entries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "collection_id": {
+ "name": "collection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_table": {
+ "name": "source_table",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_data": {
+ "name": "source_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "collection_entries_collection_id_annotated_collections_id_fk": {
+ "name": "collection_entries_collection_id_annotated_collections_id_fk",
+ "tableFrom": "collection_entries",
+ "columnsFrom": [
+ "collection_id"
+ ],
+ "tableTo": "annotated_collections",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "collection_entries_collection_id_source_table_source_id_unique": {
+ "name": "collection_entries_collection_id_source_table_source_id_unique",
+ "columns": [
+ "collection_id",
+ "source_table",
+ "source_id"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.incidents": {
+ "name": "incidents",
+ "schema": "",
+ "columns": {
+ "collection_id": {
+ "name": "collection_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "parent_alerts_table": {
+ "name": "parent_alerts_table",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "incident_type": {
+ "name": "incident_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_party": {
+ "name": "responsible_party",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'suspected'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "impact_description": {
+ "name": "impact_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "supporting_evidence": {
+ "name": "supporting_evidence",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "incidents_parent_alerts_table_idx": {
+ "name": "incidents_parent_alerts_table_idx",
+ "columns": [
+ {
+ "expression": "parent_alerts_table",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "incidents_collection_id_annotated_collections_id_fk": {
+ "name": "incidents_collection_id_annotated_collections_id_fk",
+ "tableFrom": "incidents",
+ "columnsFrom": [
+ "collection_id"
+ ],
+ "tableTo": "annotated_collections",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "views": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json
index 9e1c7db4..949cbd2b 100644
--- a/server/database/migrations/meta/_journal.json
+++ b/server/database/migrations/meta/_journal.json
@@ -71,6 +71,13 @@
"when": 1783506319409,
"tag": "0009_strip_mapeo_table_from_view_config",
"breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "7",
+ "when": 1783600000000,
+ "tag": "0010_rename_mapeo_category_ids",
+ "breakpoints": true
}
]
}
diff --git a/server/utils/index.ts b/server/utils/index.ts
index 75375f2f..8a0fabde 100644
--- a/server/utils/index.ts
+++ b/server/utils/index.ts
@@ -1,5 +1,8 @@
import type { BasemapConfig, MapboxStyleConfig, ViewConfig } from "@/types";
+import { sql } from "drizzle-orm";
+
+import { warehouseDb } from "@/server/database/dbConnection";
import { fetchTableNames } from "@/server/database/dbOperations";
export type ParsedBasemaps = {
@@ -63,3 +66,35 @@ export const getFilteredTableNames = async () => {
return tableNames;
};
+
+/**
+ * Warehouse tables that expose both `g__type` and `g__coordinates` (plottable secondary datasets).
+ */
+export const getGeospatialTableNames = async (): Promise => {
+ const tableNames = await getFilteredTableNames();
+ if (tableNames.length === 0) return [];
+
+ const rows = await warehouseDb.execute(sql`
+ SELECT table_name
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND column_name IN ('g__type', 'g__coordinates')
+ AND table_name IN (${sql.join(
+ tableNames.map((name) => sql`${name}`),
+ sql`, `,
+ )})
+ GROUP BY table_name
+ HAVING COUNT(DISTINCT column_name) = 2
+ `);
+
+ const geospatial = new Set(
+ rows
+ .map(
+ (row: unknown) =>
+ (row as Record).table_name as string | undefined,
+ )
+ .filter((name): name is string => Boolean(name)),
+ );
+
+ return tableNames.filter((name) => geospatial.has(name));
+};
diff --git a/tests/db-seed/guardianconnector.sql b/tests/db-seed/guardianconnector.sql
index 286bd7df..6b8071b5 100644
--- a/tests/db-seed/guardianconnector.sql
+++ b/tests/db-seed/guardianconnector.sql
@@ -33,13 +33,13 @@ INSERT INTO views (view_name, view_type, primary_dataset, secondary_dataset, vie
'alerts',
'fake_alerts',
'mapeo_data',
- '{"EMBED_MEDIA":"YES","MEDIA_BASE_PATH_ALERTS":"","MEDIA_BASE_PATH":"","LOGO_URL":"https://conservationmetrics.com/wp-content/themes/conservation-metrics/images/logo-conservation-metrics.png","MAPBOX_STYLE":{"version":8,"sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#f8fafc"}}]},"MAPBOX_PROJECTION":"globe","MAPBOX_CENTER_LATITUDE":"38","MAPBOX_CENTER_LONGITUDE":"-79","MAPBOX_ZOOM":7,"MAPBOX_PITCH":0,"MAPBOX_BEARING":0,"MAPBOX_3D":false,"MAPEO_CATEGORY_IDS":"threat","MAP_LEGEND_LAYER_IDS":"road-primary,aerialway","ALERT_RESOURCES":"NO","MAPBOX_ACCESS_TOKEN":"{MAPBOX_ACCESS_TOKEN}","PLANET_API_KEY":"{PLANET_API_KEY}","ROUTE_LEVEL_PERMISSION":"anyone"}'
+ '{"EMBED_MEDIA":"YES","MEDIA_BASE_PATH_ALERTS":"","MEDIA_BASE_PATH":"","LOGO_URL":"https://conservationmetrics.com/wp-content/themes/conservation-metrics/images/logo-conservation-metrics.png","MAPBOX_STYLE":{"version":8,"sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#f8fafc"}}]},"MAPBOX_PROJECTION":"globe","MAPBOX_CENTER_LATITUDE":"38","MAPBOX_CENTER_LONGITUDE":"-79","MAPBOX_ZOOM":7,"MAPBOX_PITCH":0,"MAPBOX_BEARING":0,"MAPBOX_3D":false,"SECONDARY_CATEGORY_IDS":"threat","MAP_LEGEND_LAYER_IDS":"road-primary,aerialway","ALERT_RESOURCES":"NO","MAPBOX_ACCESS_TOKEN":"{MAPBOX_ACCESS_TOKEN}","PLANET_API_KEY":"{PLANET_API_KEY}","ROUTE_LEVEL_PERMISSION":"anyone"}'
),
(
'gfw_alerts_viirs',
'alerts',
'gfw_alerts_viirs',
'mapeo_data',
- '{"EMBED_MEDIA":"NO","MEDIA_BASE_PATH_ALERTS":"","MEDIA_BASE_PATH":"","MAPBOX_STYLE":{"version":8,"sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#f8fafc"}}]},"MAPBOX_PROJECTION":"globe","MAPBOX_CENTER_LATITUDE":"1.20","MAPBOX_CENTER_LONGITUDE":"34.60","MAPBOX_ZOOM":8,"MAPBOX_PITCH":0,"MAPBOX_BEARING":0,"MAPBOX_3D":false,"MAPEO_CATEGORY_IDS":"threat","MAP_LEGEND_LAYER_IDS":"road-primary,aerialway","ALERT_RESOURCES":"NO","MAPBOX_ACCESS_TOKEN":"{MAPBOX_ACCESS_TOKEN}","PLANET_API_KEY":"{PLANET_API_KEY}","ROUTE_LEVEL_PERMISSION":"anyone"}'
+ '{"EMBED_MEDIA":"NO","MEDIA_BASE_PATH_ALERTS":"","MEDIA_BASE_PATH":"","MAPBOX_STYLE":{"version":8,"sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#f8fafc"}}]},"MAPBOX_PROJECTION":"globe","MAPBOX_CENTER_LATITUDE":"1.20","MAPBOX_CENTER_LONGITUDE":"34.60","MAPBOX_ZOOM":8,"MAPBOX_PITCH":0,"MAPBOX_BEARING":0,"MAPBOX_3D":false,"SECONDARY_CATEGORY_IDS":"threat","MAP_LEGEND_LAYER_IDS":"road-primary,aerialway","ALERT_RESOURCES":"NO","MAPBOX_ACCESS_TOKEN":"{MAPBOX_ACCESS_TOKEN}","PLANET_API_KEY":"{PLANET_API_KEY}","ROUTE_LEVEL_PERMISSION":"anyone"}'
)
ON CONFLICT (view_type, primary_dataset) DO NOTHING;
diff --git a/tests/unit/components/AlertsDashboard.test.ts b/tests/unit/components/AlertsDashboard.test.ts
index 8b8b6e53..89420071 100644
--- a/tests/unit/components/AlertsDashboard.test.ts
+++ b/tests/unit/components/AlertsDashboard.test.ts
@@ -66,7 +66,7 @@ const baseProps: InstanceType["$props"] = {
mapboxZoom: 2,
mapbox3d: false,
mapbox3dTerrainExaggeration: 1.5,
- mapeoData: null,
+ secondaryData: null,
primaryDataset: "test_alerts",
secondaryDataset: "mapeo_data",
mediaBasePath: "",
@@ -441,14 +441,14 @@ describe("AlertsDashboard component", () => {
selectedSources: Array<{
source_table: string;
source_id: string;
- feature_type: "alert" | "mapeo";
+ feature_type: "alert" | "secondary";
}>;
};
vm.selectedSources = [
{
source_table: "mapeo_data",
source_id: "test1",
- feature_type: "mapeo",
+ feature_type: "secondary",
},
];
await flushPromises();
diff --git a/tests/unit/components/IncidentsSidebar.test.ts b/tests/unit/components/IncidentsSidebar.test.ts
index fb15a1df..3f7706de 100644
--- a/tests/unit/components/IncidentsSidebar.test.ts
+++ b/tests/unit/components/IncidentsSidebar.test.ts
@@ -21,7 +21,7 @@ describe("IncidentsSidebar component", () => {
type SelectedSource = {
source_table: string;
source_id: string;
- feature_type: "alert" | "mapeo";
+ feature_type: "alert" | "secondary";
notes?: string;
};
@@ -49,7 +49,7 @@ describe("IncidentsSidebar component", () => {
];
const mockSelectedSources: SelectedSource[] = [
- { source_table: "mapeo_data", source_id: "source1", feature_type: "mapeo" },
+ { source_table: "mapeo_data", source_id: "source1", feature_type: "secondary" },
{ source_table: "alerts", source_id: "source2", feature_type: "alert" },
];
@@ -127,7 +127,7 @@ describe("IncidentsSidebar component", () => {
const summary = wrapper.find(".selected-sources-summary");
expect(summary.exists()).toBe(true);
expect(summary.text()).toContain("incidents.selectedAlertsCount");
- expect(summary.text()).toContain("incidents.selectedMapeoCount");
+ expect(summary.text()).toContain("incidents.selectedSecondaryCount");
});
it("emits clearSources when clear all button is clicked", async () => {
diff --git a/tests/unit/components/sourceTableDetermination.test.ts b/tests/unit/components/sourceTableDetermination.test.ts
index df63e58b..6cc85d6b 100644
--- a/tests/unit/components/sourceTableDetermination.test.ts
+++ b/tests/unit/components/sourceTableDetermination.test.ts
@@ -28,7 +28,7 @@ vi.mock("vue-router", () => ({
*
* If the route structure changes, this function must be updated accordingly.
*
- * @param layerId - The layer ID (e.g., "most-recent-alerts-polygon", "mapeo-data")
+ * @param layerId - The layer ID (e.g., "most-recent-alerts-polygon", "secondary-data")
* @param routeParams - The route params object (should contain `tablename` for alert layers)
* @returns The source table name, or empty string if unable to determine
*/
@@ -47,8 +47,8 @@ function determineSourceTable(
// For alert layers, use the table name from route params
// Route structure: /alerts/[tablename]
return (tableName as string) || "";
- } else if (layerId === "mapeo-data") {
- // For Mapeo layers, use hardcoded table name
+ } else if (layerId === "secondary-data") {
+ // Secondary companion table (fixture / common seed uses mapeo_data)
return "mapeo_data";
}
@@ -136,9 +136,9 @@ describe("Source Table Determination", () => {
});
});
- describe("Mapeo layers - hardcoded table name", () => {
- it("returns 'mapeo_data' for mapeo-data layer", () => {
- const layerId = "mapeo-data";
+ describe("Secondary layers - companion table name", () => {
+ it("returns 'mapeo_data' for secondary-data layer", () => {
+ const layerId = "secondary-data";
const routeParams = { tablename: "fake_alerts" }; // Should be ignored
const result = determineSourceTable(layerId, routeParams);
@@ -147,7 +147,7 @@ describe("Source Table Determination", () => {
});
it("returns 'mapeo_data' even if route params are missing", () => {
- const layerId = "mapeo-data";
+ const layerId = "secondary-data";
const routeParams = {};
const result = determineSourceTable(layerId, routeParams);
@@ -265,7 +265,7 @@ describe("Source Table Determination", () => {
expect(result).toBe("fake_alerts");
});
- it("handles mapeo-data with different casing", () => {
+ it("handles secondary-data with different casing", () => {
const layerId = "Mapeo-Data"; // Wrong case
const routeParams = { tablename: "fake_alerts" };
diff --git a/tests/unit/composables/useCopyConfig.test.ts b/tests/unit/composables/useCopyConfig.test.ts
index 1b6176f2..a2d9225f 100644
--- a/tests/unit/composables/useCopyConfig.test.ts
+++ b/tests/unit/composables/useCopyConfig.test.ts
@@ -167,7 +167,7 @@ describe("useCopyConfig", () => {
primaryDataset: "fake_alerts",
viewType: "alerts",
viewName: "Fake Alerts",
- viewConfig: { MAPEO_CATEGORY_IDS: "threat" },
+ viewConfig: { SECONDARY_CATEGORY_IDS: "threat" },
}),
{
...makeRow({
@@ -175,7 +175,7 @@ describe("useCopyConfig", () => {
primaryDataset: "gfw_alerts_viirs",
viewType: "alerts",
viewName: "GFW Alerts",
- viewConfig: { MAPEO_CATEGORY_IDS: "threat" },
+ viewConfig: { SECONDARY_CATEGORY_IDS: "threat" },
}),
secondaryDataset: "mapeo_data",
},
@@ -193,7 +193,7 @@ describe("useCopyConfig", () => {
selectedCopySource.value = otherCopySources.value[0].key;
handleConfirmCopy();
- expect(configToCopy.value).toEqual({ MAPEO_CATEGORY_IDS: "threat" });
+ expect(configToCopy.value).toEqual({ SECONDARY_CATEGORY_IDS: "threat" });
expect(secondaryDatasetToCopy.value).toBe("mapeo_data");
});
});
diff --git a/tests/unit/utils/geoUtils.test.ts b/tests/unit/utils/geoUtils.test.ts
index f217f5a2..02a3f7a6 100644
--- a/tests/unit/utils/geoUtils.test.ts
+++ b/tests/unit/utils/geoUtils.test.ts
@@ -411,25 +411,23 @@ describe("buildMinimalFeatureCollection", () => {
expect(result.features[1].id).toBe(200);
});
- it("normalizes Mapeo hex IDs to 32-bit integers when isMapeoData is true", () => {
- const mapeoData = [
+ it("normalizes 16-char hex document IDs to 32-bit integers for Mapbox", () => {
+ const hexIdData = [
{
_id: "0084cdc57c0b0280",
g__type: "Point",
g__coordinates: "[10.5, 45.2]",
- name: "Mapeo observation",
+ name: "Hex id observation",
},
{
id: "00a1b2c3d4e5f678",
g__type: "Point",
g__coordinates: "[11.0, 46.0]",
- name: "Mapeo observation (id field)",
+ name: "Hex id observation (id field)",
},
] as DataEntry[];
- const result = buildMinimalFeatureCollection(mapeoData, {
- isMapeoData: true,
- });
+ const result = buildMinimalFeatureCollection(hexIdData);
expect(result.features).toHaveLength(2);
result.features.forEach((feature) => {
@@ -438,7 +436,7 @@ describe("buildMinimalFeatureCollection", () => {
});
});
- it("skips Mapeo ID normalization for non-hex IDs", () => {
+ it("hashes non-hex IDs via the standard murmurhash path", () => {
const nonHexData = [
{
_id: "not-a-hex-id",
@@ -447,12 +445,10 @@ describe("buildMinimalFeatureCollection", () => {
},
];
- const result = buildMinimalFeatureCollection(nonHexData, {
- isMapeoData: true,
- });
+ const result = buildMinimalFeatureCollection(nonHexData);
expect(result.features).toHaveLength(1);
- expect(result.features[0].id).toBeUndefined();
+ expect(typeof result.features[0].id).toBe("number");
});
it("uses custom idField", () => {
diff --git a/types/index.ts b/types/index.ts
index bc061165..5c2c3584 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -103,7 +103,9 @@ export interface ViewConfig {
MAPBOX_STYLE?: MapboxStyleConfig; // Deprecated: use MAPBOX_BASEMAPS instead
MAPBOX_BASEMAPS?: string; // JSON string of BasemapConfig[]
MAPBOX_ZOOM?: number;
+ /** @deprecated Prefer SECONDARY_CATEGORY_IDS; kept for dual-read of unmigrated configs. */
MAPEO_CATEGORY_IDS?: string;
+ SECONDARY_CATEGORY_IDS?: string;
MAP_LEGEND_LAYER_IDS?: string;
MEDIA_BASE_PATH?: string;
MEDIA_BASE_PATH_ALERTS?: string;
@@ -332,7 +334,12 @@ export interface Incident {
};
}
-export type FeatureType = "alert" | "mapeo";
+/** "mapeo" is a legacy alias for secondary features (lookup by `_id`). */
+export type FeatureType = "alert" | "secondary" | "mapeo";
+
+export const isSecondaryFeatureType = (
+ featureType: FeatureType | string | undefined,
+): boolean => featureType === "secondary" || featureType === "mapeo";
export interface CollectionEntryInput {
source_table: string;
diff --git a/utils/geoUtils.ts b/utils/geoUtils.ts
index 350e2360..d2d66c0c 100644
--- a/utils/geoUtils.ts
+++ b/utils/geoUtils.ts
@@ -26,22 +26,14 @@ export interface SpatialDataOptions {
includeAllProperties?: boolean;
filterColumn?: string;
generateId?: (entry: DataEntry) => number | string | undefined;
- /**
- * When true, enables Mapeo-specific ID normalization. Mapeo document IDs are
- * 64-bit hex strings (e.g. "0084cdc57c0b0280") that exceed JavaScript's safe
- * integer range (2^53 - 1). Mapbox requires feature IDs to be either Numbers
- * or strings safely castable to Numbers; without normalization, Mapbox falls
- * back to undefined IDs and setFeatureState() fails.
- *
- * This option resolves the ID from both `_id` and `id` fields (Mapeo data may
- * use either), validates the 16-char hex format, and hashes via MurmurHash to
- * produce a safe 32-bit integer.
- *
- * Reference: https://stackoverflow.com/questions/72040370/why-are-my-dataset-features-ids-undefined-in-mapbox-gl-while-i-have-set-them
- */
- isMapeoData?: boolean;
}
+/** 16-char hex document IDs (e.g. Mapeo) exceed JS safe integers; hash for Mapbox. */
+const HEX_DOCUMENT_ID = /^[0-9a-fA-F]{16}$/;
+
+export const isHexDocumentId = (value: unknown): value is string =>
+ typeof value === "string" && HEX_DOCUMENT_ID.test(value);
+
/** Checks if a number is a valid geographic coordinate. */
export const isValidCoordinate = (coord: number): boolean => {
return coord != null && !isNaN(coord) && coord >= -180 && coord <= 180;
@@ -366,7 +358,6 @@ export const buildMinimalFeatureCollection = (
includeAllProperties = false,
filterColumn,
generateId,
- isMapeoData = false,
} = options;
const colorMap = new Map();
@@ -390,18 +381,14 @@ export const buildMinimalFeatureCollection = (
if (generateId) {
featureId = generateId(entry);
- } else if (isMapeoData) {
- // Mapeo IDs can be in _id or id; validate 16-char hex before hashing
- const mapeoId = entry._id || entry.id;
- if (
- mapeoId &&
- typeof mapeoId === "string" &&
- mapeoId.match(/^[0-9a-fA-F]{16}$/)
- ) {
- featureId = murmurhash.v3(mapeoId);
+ } else {
+ // Hex document IDs (e.g. Mapeo) may live in `_id` or `id`; hash for Mapbox.
+ const documentId = entry._id || entry.id;
+ if (isHexDocumentId(documentId)) {
+ featureId = murmurhash.v3(documentId);
+ } else if (rawId) {
+ featureId = murmurhash.v3(String(rawId));
}
- } else if (rawId) {
- featureId = murmurhash.v3(rawId);
}
const properties: GeoJsonProperties = {};
diff --git a/utils/mapGLHelpers.ts b/utils/mapGLHelpers.ts
index 09e6d622..827ea3ec 100644
--- a/utils/mapGLHelpers.ts
+++ b/utils/mapGLHelpers.ts
@@ -120,7 +120,7 @@ const getMapboxLayersForLegend = (
export const prepareMapLegendLayers = (
map: mapboxgl.Map,
mapLegendLayerIds: string | null,
- mapeoLegendColor?: string | null,
+ secondaryLegendColor?: string | null,
): unknown[] | undefined => {
if (!mapLegendLayerIds || !map.isStyleLoaded()) {
return;
@@ -154,8 +154,8 @@ export const prepareMapLegendLayers = (
}
const layerColorColumn = (layerColor as string[])[3];
- if (Array.isArray(layerColorColumn) && mapeoLegendColor) {
- layerColor = mapeoLegendColor;
+ if (Array.isArray(layerColorColumn) && secondaryLegendColor) {
+ layerColor = secondaryLegendColor;
}
let formattedId = layerId
From a104fb5e11ab41347590a669a729eda79447765b Mon Sep 17 00:00:00 2001
From: conservationtimothy
Date: Fri, 31 Jul 2026 14:36:03 +0100
Subject: [PATCH 2/4] chore: remove unusued import
---
server/api/[table]/alerts.ts | 4 ++--
server/api/config/index.get.ts | 5 +----
tests/unit/components/IncidentsSidebar.test.ts | 6 +++++-
3 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/server/api/[table]/alerts.ts b/server/api/[table]/alerts.ts
index 850317cd..d3b637eb 100644
--- a/server/api/[table]/alerts.ts
+++ b/server/api/[table]/alerts.ts
@@ -18,7 +18,7 @@ import { buildMinimalFeatureCollection } from "@/utils/geoUtils";
import { validatePermissions } from "@/utils/accessControls";
import { parseBasemaps } from "@/server/utils";
import { buildRequiredAlertsProjection } from "@/server/utils/alertsProjection";
-import { parseAndValidateLimit, getTableParam } from "@/server/utils/dbHelpers";
+import { parseAndValidateLimit } from "@/server/utils/dbHelpers";
import type { H3Event } from "h3";
import type {
@@ -60,7 +60,7 @@ const resolveSecondaryCategoryIds = (
tableConfig.SECONDARY_CATEGORY_IDS || tableConfig.MAPEO_CATEGORY_IDS;
export default defineEventHandler(async (event: H3Event) => {
- const table = getTableParam(event);
+ const { table } = event.context.params as { table: string };
const limit = parseAndValidateLimit(event);
const {
diff --git a/server/api/config/index.get.ts b/server/api/config/index.get.ts
index 3a81736e..8c4930ea 100644
--- a/server/api/config/index.get.ts
+++ b/server/api/config/index.get.ts
@@ -1,8 +1,5 @@
import { fetchViewConfigRows } from "@/server/database/dbOperations";
-import {
- getFilteredTableNames,
- getGeospatialTableNames,
-} from "@/server/utils";
+import { getFilteredTableNames, getGeospatialTableNames } from "@/server/utils";
import { validateUserSession } from "@/utils/accessControls";
import type { H3Event } from "h3";
diff --git a/tests/unit/components/IncidentsSidebar.test.ts b/tests/unit/components/IncidentsSidebar.test.ts
index 3f7706de..83db98f5 100644
--- a/tests/unit/components/IncidentsSidebar.test.ts
+++ b/tests/unit/components/IncidentsSidebar.test.ts
@@ -49,7 +49,11 @@ describe("IncidentsSidebar component", () => {
];
const mockSelectedSources: SelectedSource[] = [
- { source_table: "mapeo_data", source_id: "source1", feature_type: "secondary" },
+ {
+ source_table: "mapeo_data",
+ source_id: "source1",
+ feature_type: "secondary",
+ },
{ source_table: "alerts", source_id: "source2", feature_type: "alert" },
];
From 3cc3e16027e680716506314374e90cd0cb249596 Mon Sep 17 00:00:00 2001
From: conservationtimothy
Date: Fri, 31 Jul 2026 14:48:57 +0100
Subject: [PATCH 3/4] prettier write json
---
.../migrations/meta/0010_snapshot.json | 27 +++++--------------
1 file changed, 6 insertions(+), 21 deletions(-)
diff --git a/server/database/migrations/meta/0010_snapshot.json b/server/database/migrations/meta/0010_snapshot.json
index 2220c7df..f1fe40de 100644
--- a/server/database/migrations/meta/0010_snapshot.json
+++ b/server/database/migrations/meta/0010_snapshot.json
@@ -78,10 +78,7 @@
"uniqueConstraints": {
"views_view_type_primary_dataset_unique": {
"name": "views_view_type_primary_dataset_unique",
- "columns": [
- "view_type",
- "primary_dataset"
- ],
+ "columns": ["view_type", "primary_dataset"],
"nullsNotDistinct": false
}
},
@@ -233,13 +230,9 @@
"collection_entries_collection_id_annotated_collections_id_fk": {
"name": "collection_entries_collection_id_annotated_collections_id_fk",
"tableFrom": "collection_entries",
- "columnsFrom": [
- "collection_id"
- ],
+ "columnsFrom": ["collection_id"],
"tableTo": "annotated_collections",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -248,11 +241,7 @@
"uniqueConstraints": {
"collection_entries_collection_id_source_table_source_id_unique": {
"name": "collection_entries_collection_id_source_table_source_id_unique",
- "columns": [
- "collection_id",
- "source_table",
- "source_id"
- ],
+ "columns": ["collection_id", "source_table", "source_id"],
"nullsNotDistinct": false
}
},
@@ -336,13 +325,9 @@
"incidents_collection_id_annotated_collections_id_fk": {
"name": "incidents_collection_id_annotated_collections_id_fk",
"tableFrom": "incidents",
- "columnsFrom": [
- "collection_id"
- ],
+ "columnsFrom": ["collection_id"],
"tableTo": "annotated_collections",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
From 57ac618be84b60dcbd5d7ef48d9b7e0416951dd9 Mon Sep 17 00:00:00 2001
From: conservationtimothy
Date: Mon, 3 Aug 2026 19:22:03 +0100
Subject: [PATCH 4/4] Show configured secondary dataset name in map legend
---
components/AlertsDashboard.vue | 6 +++++-
components/shared/MapLegend.vue | 12 +++++-------
2 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/components/AlertsDashboard.vue b/components/AlertsDashboard.vue
index 7cd46ba1..55bcd981 100644
--- a/components/AlertsDashboard.vue
+++ b/components/AlertsDashboard.vue
@@ -1385,7 +1385,11 @@ const prepareMapLegendContent = () => {
if (props.secondaryData) {
legendItems.push({
id: "secondary-data",
- name: "Secondary data",
+ name: props.secondaryDataset
+ ? props.secondaryDataset
+ .replace(/_/g, " ")
+ .replace(/^\w/, (character) => character.toUpperCase())
+ : t("secondaryData"),
type: "circle",
color: secondaryDataColor.value || "#000000",
visible: true,
diff --git a/components/shared/MapLegend.vue b/components/shared/MapLegend.vue
index 03304ebe..6a1503e7 100644
--- a/components/shared/MapLegend.vue
+++ b/components/shared/MapLegend.vue
@@ -95,13 +95,11 @@ watch(
{{
- item.name === "Secondary data"
- ? $t("secondaryData")
- : item.name === "Most recent alerts"
- ? $t("mostRecentAlerts")
- : item.name === "Previous alerts"
- ? $t("previousAlerts")
- : item.name
+ item.name === "Most recent alerts"
+ ? $t("mostRecentAlerts")
+ : item.name === "Previous alerts"
+ ? $t("previousAlerts")
+ : item.name
}}