From 4562a9e9b9e43efbddcb7cc4b25e6566a6adecc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Katharina=20W=C3=BCnsche?= <37192329+katharinawuensche@users.noreply.github.com> Date: Mon, 25 Aug 2025 21:27:37 +0200 Subject: [PATCH 01/15] Custom map icons (#38) This PR allows users to configure custom icons for entity types on the map via the CMS. --------- Co-authored-by: oliviareichl --- components/data-map-view.vue | 169 +- components/entity-geo-map.vue | 1 + components/geo-map.client.vue | 192 +- components/source-content.vue | 1 - composables/use-create-linked-entities.ts | 2 +- composables/use-get-entity.ts | 2 - config/project.config.ts | 9 +- lib/api-client/api.ts | 558 +- nuxt.config.ts | 6 +- package.json | 8 +- pnpm-lock.yaml | 27 +- project.config.json | 14 +- public/admin/config.yml | 1822 ++++- public/assets/icons/icons.json | 8909 +++++++++++++++++++++ scripts/generate-cms-config.ts | 39 + types/api.ts | 7 + utils/create-geojson-feature.ts | 9 +- 17 files changed, 11239 insertions(+), 536 deletions(-) create mode 100644 public/assets/icons/icons.json create mode 100644 scripts/generate-cms-config.ts diff --git a/components/data-map-view.vue b/components/data-map-view.vue index 226357bf..0cfa5dbb 100644 --- a/components/data-map-view.vue +++ b/components/data-map-view.vue @@ -8,6 +8,7 @@ import * as v from "valibot"; import type { SearchFormData } from "@/components/search-form.vue"; import type { EntityFeature } from "@/composables/use-create-entity"; import { categories, operatorMap } from "@/composables/use-get-search-results"; +import type { CustomIconEntry } from "@/types/api"; import type { GeoJsonFeature } from "@/utils/create-geojson-feature"; import { project } from "../config/project.config"; @@ -71,7 +72,7 @@ const { data, isPending, isPlaceholderData } = useGetSearchResults( search.length > 0 ? [{ [category]: [{ operator: operator, values: [search], logicalOperator: "and" }] }] : [], - show: ["geometry", "when", "relations"], + show: ["geometry", "when", "relations", "types"], centroid: true, relation_type: ["P26", "P27"], system_classes: project.map.mapDisplayedSystemClasses, @@ -128,13 +129,77 @@ const mode = computed(() => { * because `maplibre-gl` will serialize geojson features when sending them to the webworker. */ const features = computed(() => { - return entities.value + const mappedFeatures = entities.value .filter((entity) => { + if (!entity.geometry) return false; + return entity.geometry; + + /* && + !entity.types?.find((type) => { + return project.map.customIconConfig.find((config) => { + return config.entityType === type.label; + }); + }) */ }) .map((entity) => { - return createGeoJsonFeature(entity); + const feature = createGeoJsonFeature(entity); + const customConfig = Object.entries(project.map.customIconConfig).findLast((entry) => { + return entity.types?.find((type) => { + return getUnprefixedId(type.identifier ?? "") === String(entry[1].entityType); + }); + }); + if (customConfig != null) { + feature.properties.color = customConfig[1].color; + feature.properties.size = 10; + feature.properties.isIcon = true; + feature.properties.isDisplayed = true; + } + + return feature; }); + + mappedFeatures.forEach((feature, index, self) => { + let foundIcon; + + // For GeometryCollections such as areas + if (feature.geometry.type === "GeometryCollection") { + feature.geometry.geometries.forEach((geo) => { + if (geo.type !== "Point") return; + const coords = geo.coordinates.join(","); + const matchingCoordinatesFeatures = self.filter((f) => { + if (f.geometry.type !== "Point") return false; + return f.geometry.coordinates.join(",") === coords; + }); + if ( + matchingCoordinatesFeatures.some((f) => { + return f.properties.isIcon; + }) + ) + foundIcon = true; + }); + } + + // For single points + if (feature.geometry.type === "Point") { + const coords = feature.geometry.coordinates.join(","); + const matchingCoordinatesFeatures = self.filter((f) => { + if (f.geometry.type !== "Point") return false; + return f.geometry.coordinates.join(",") === coords; + }); + foundIcon = matchingCoordinatesFeatures.some((f) => { + return f.properties.isIcon; + }); + } + + if (foundIcon) { + feature.properties.isDisplayed = false; + } else { + feature.properties.isDisplayed = true; + } + }); + + return mappedFeatures; }); const movements = computed(() => { @@ -207,6 +272,13 @@ const events = computed(() => { .filter((feature) => { return feature.geometry && "geometries" in feature.geometry; }) + .filter((feature) => { + return !feature.types?.find((type) => { + return project.map.customIconConfig.find((config) => { + return String(config.entityType) === getUnprefixedId(type.identifier ?? ""); + }); + }); + }) .map((feature) => { assert(feature.geometry, "Feature has no geometry"); assert("geometries" in feature.geometry, "Feature has no geometries"); @@ -226,10 +298,36 @@ const events = computed(() => { return featureClone; }); - return event.map((entity) => { + const mappedEvents = event.map((entity) => { let feature = createGeoJsonFeature(entity); return feature; }); + + mappedEvents.forEach((feature) => { + if (feature.geometry.type !== "GeometryCollection") { + return; + } + const eventPoint = feature.geometry.geometries[0]; + if (!eventPoint || eventPoint.type !== "Point") return; + const coords = eventPoint.coordinates.join(","); + + const matchingCoordinatesFeatures = features.value.filter((f) => { + if (f.geometry.type !== "Point") return false; + return f.geometry.coordinates.join(",") === coords; + }); + + const foundIcon = matchingCoordinatesFeatures.some((f) => { + return f.properties.isIcon; + }); + + if (foundIcon) { + feature.properties.isDisplayed = false; + } else { + feature.properties.isDisplayed = true; + } + }); + + return mappedEvents; }); const centerpoints = computed(() => { @@ -252,6 +350,7 @@ interface onLayerClickParams { } function onLayerClick({ features, targetCoordinates }: onLayerClickParams) { + console.log("Layer click", features); const entitiesMap = new Map(); features.forEach((feature) => { @@ -342,12 +441,12 @@ function setCoordinates(entity: EntityFeature, coordinates: Ref<[number, number] watchEffect(() => { if (mode.value && selection.value) { - console.log("mode & selection set", selection.value); + // console.log("mode & selection set", selection.value); const entity = entities.value.find((feature) => { const id = getUnprefixedId(feature["@id"]); return id === selection.value; }); - console.log("Entity: ", entity, entities.value); + // console.log("Entity: ", entity, entities.value); if (entity) { setCoordinates(entity, selectionCoordinates); @@ -364,7 +463,7 @@ watchEffect(() => { }; } - console.log(detailOnMap.value); + // console.log(detailOnMap.value); detailSelectionCoordinates.value = undefined; if (detailOnMap.value) { const detailEntity = entities.value.find((feature) => { @@ -378,12 +477,12 @@ watchEffect(() => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (detailSelectionCoordinates.value === undefined) return; - console.log( - "Detail Coordinates: ", - detailSelectionCoordinates, - popover.value, - detailEntity, - ); + // console.log( + // "Detail Coordinates: ", + // detailSelectionCoordinates, + // popover.value, + // detailEntity, + // ); popover.value = { coordinates: detailSelectionCoordinates.value, entities: [detailEntity], @@ -441,6 +540,49 @@ const multipleMovements = useGetChainedEvents( function setMovementId({ id }: { id: string | null }) { return (movementId.value = id ? parseInt(id) : null); } + +const customIconEntries = computed(() => { + const entries: Record = {}; + entities.value.forEach((entity) => { + const foundType = entity.types?.findLast((type) => { + return project.map.customIconConfig.find((config) => { + return String(config.entityType) === getUnprefixedId(type.identifier ?? ""); + }); + }); + const unprefixedType = getUnprefixedId(foundType?.identifier ?? ""); + if (foundType) { + if (!(unprefixedType in entries)) { + const configEntry = project.map.customIconConfig.find((config) => { + return String(config.entityType) === unprefixedType; + }); + const customEntry: CustomIconEntry = { + type: foundType, + icon: configEntry?.iconName, + color: configEntry?.color, + entities: [], + }; + entries[unprefixedType] = customEntry; + } + if (entity.geometry) { + const feature = createGeoJsonFeature(entity); + const customConfig = Object.entries(project.map.customIconConfig).findLast((entry) => { + return entity.types?.find((type) => { + return getUnprefixedId(type.identifier ?? "") === String(entry[1].entityType); + }); + }); + if (customConfig != null) { + feature.properties.color = customConfig[1].color; + feature.properties.size = 10; + feature.properties.isIcon = true; + } + + entries[unprefixedType]?.entities.push(feature); + } + } + }); + console.log("custom entries: ", entries); + return entries; +});