Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 69 additions & 55 deletions components/AlertsDashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
Expand All @@ -233,7 +233,7 @@ watch(
skipNextWatch = true;

let displayRecord: Record<string, unknown>;
if (isMapeoFeature) {
if (isSecondaryFeature) {
displayRecord = fullRecord
? transformSurveyEntry(fullRecord)
: minimalFeature;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(",")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
);

Expand Down Expand Up @@ -1250,16 +1260,16 @@ 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 () => {
const promises = [];
if (props.alertsData) {
promises.push(addAlertsData());
}
if (props.mapeoData) {
promises.push(addMapeoData());
if (props.secondaryData) {
promises.push(addSecondaryData());
}
await Promise.all(promises);
prepareMapLegendContent();
Expand All @@ -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) {
Expand Down Expand Up @@ -1371,13 +1381,17 @@ 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: props.secondaryDataset
? props.secondaryDataset
.replace(/_/g, " ")
.replace(/^\w/, (character) => character.toUpperCase())
: t("secondaryData"),
type: "circle",
color: mapeoDataColor.value || "#000000",
color: secondaryDataColor.value || "#000000",
visible: true,
});
}
Expand Down Expand Up @@ -1409,7 +1423,7 @@ const prepareMapLegendContent = () => {
const additionalLayers = prepareMapLegendLayers(
map.value,
props.mapLegendLayerIds,
mapeoDataColor.value,
secondaryDataColor.value,
);
if (additionalLayers) {
legendItems.push(...(additionalLayers as MapLegendItem[]));
Expand Down Expand Up @@ -1464,8 +1478,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);
}
Expand All @@ -1475,7 +1489,7 @@ const toggleLayerVisibility = (item: MapLegendItem) => {
}
});
} else {
// Handle individual layers (mapeo-data, etc.)
// Handle individual layers (secondary-data, etc.)
utilsToggleLayerVisibility(map.value, item);
}
};
Expand Down Expand Up @@ -1681,8 +1695,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);
}
Expand All @@ -1696,7 +1710,7 @@ const resetToInitialState = () => {
}
});
} else {
// Handle individual layers (mapeo-data, etc.)
// Handle individual layers (secondary-data, etc.)
utilsToggleLayerVisibility(map.value, item);
}
});
Expand Down Expand Up @@ -1729,15 +1743,15 @@ onBeforeUnmount(() => {
:calculate-hectares="calculateHectares"
:date-options="dateOptions"
:export-table-name="
isMapeo ? secondaryDataset || primaryDataset : primaryDataset
isSecondary ? secondaryDataset || primaryDataset : primaryDataset
"
:feature="selectedFeature"
:feature-loading="selectedFeatureLoading"
:feature-geojson="localAlertsData"
: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"
Expand Down
17 changes: 11 additions & 6 deletions components/alerts/IncidentsSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
buildIncidentMetadataCsv,
triggerTextDownload,
} from "@/utils/incidentHelpers";
import { isSecondaryFeatureType } from "@/types";
import type {
AnnotatedCollection,
CollectionEntry,
Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -443,8 +448,8 @@ const handleClose = () => {
</p>
<p>
{{
$t("incidents.selectedMapeoCount", {
count: selectedSourceSummary.mapeoData,
$t("incidents.selectedSecondaryCount", {
count: selectedSourceSummary.secondary,
})
}}
</p>
Expand Down
16 changes: 12 additions & 4 deletions components/config/ConfigAlerts.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Tag[]> = {
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 }))
: [],
};

Expand All @@ -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,
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concept of a "category" is unique to Mapeo.

If we are generalizing to any secondary dataset, then we need more general language and handling.

Currently, for Mapeo data, we hardcode a category field somewhere, and then in config, allow the user to pick the categories for which they want data to show on the map:

Image

Now we actually need two fields:

  1. "Filter data by column" (to replaced hard-coded category field for Mapeo)
  2. "Pick the values to show on the map" (the current equivalent of "Mapeo Category IDs to show on the alerts map")

We actually already have a "Filter data by column" field - we just don't use it for the alerts dashboard.

Confusingly, we also have a "Which values to filter out from the column" which does the opposite of what we need for (2).

But we can reuse the existing "Filter data by column" field, and then turn "Category IDs to show from the secondary dataset on the alerts map" into that?

Maybe this should be a follow-on PR, though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened a PR #566

};
</script>

Expand All @@ -47,7 +55,7 @@ const handleTagsChanged = (key: string, newTags: Tag[]): void => {
>
{{ $t(toCamelCase(key)) }}
</label>
<template v-if="key === 'MAPEO_CATEGORY_IDS'">
<template v-if="key === 'SECONDARY_CATEGORY_IDS'">
<VueTagsInput
class="tag-field"
:tags="tags[key]"
Expand Down
Loading
Loading