diff --git a/components/MyLeafletMap.vue b/components/MyLeafletMap.vue index cc07c4b..77b1838 100644 --- a/components/MyLeafletMap.vue +++ b/components/MyLeafletMap.vue @@ -35,13 +35,19 @@ function findValueByKey(obj: unknown, key: string): string | number | undefined return undefined; const record = obj as Record; - - if (key in record) { - const value = record[key]; - if (typeof value === 'string' || typeof value === 'number') - return value; + const targetKey = key.toLowerCase(); + + // Case-insensitive key lookup + for (const k of Object.keys(record)) { + if (k.toLowerCase() === targetKey) { + const value = record[k]; + if (typeof value === 'string' || typeof value === 'number') { + return value; + } + } } + // Recursively search nested objects for (const value of Object.values(record)) { const result = findValueByKey(value, key); if (result !== undefined) @@ -73,10 +79,13 @@ function generateLabels(data: GeoJSON.FeatureCollection): Map { if (legendDisplayOption[0] === 'default') { uniqueValues.forEach((value) => { - if (value === undefined) { + if (value === undefined) return; - } - const match = legendDetail.find((item: LegendDetails) => item.label === value); + + const match = legendDetail.find( + (item: LegendDetails) => item.label.toLowerCase() === String(value).toLowerCase(), + ); + if (match?.color) { colorMap.set(value, match.color); } @@ -177,7 +186,7 @@ function generateLabels(data: GeoJSON.FeatureCollection): Map { }); } - if (leafletMap) { + if (leafletMap && legend.onAdd) { legend.addTo(leafletMap); legendControl = legend; } @@ -209,7 +218,22 @@ function renderMarkers(data: GeoJSON.FeatureCollection | undefined) { data.features.forEach((feature) => { const legendOption = feature.properties?.options?.legend_option; const labelOption = feature.properties?.options?.label_option; - let key = labelOption ? feature.properties?.[labelOption] : undefined; + let key: string = 'default'; + + if (labelOption && feature.properties) { + const normalizedKey = Object.keys(feature.properties).find( + k => k.toLowerCase() === labelOption.toLowerCase(), + ); + if (normalizedKey) { + const value = feature.properties[normalizedKey]; + if (typeof value === 'string') { + key = value.trim(); + } + else { + key = value; + } + } + } if (legendOption === 'colorVarient') { key = feature.properties?.__binLabel; @@ -264,6 +288,10 @@ function renderMarkers(data: GeoJSON.FeatureCollection | undefined) { geoJsonLayer.addTo(leafletMap as L.Map); geoJsonLayers.push(geoJsonLayer); }); + const bounds = new L.LatLngBounds(geoJsonLayers.map(layer => [layer.getBounds().getNorthEast(), layer.getBounds().getSouthWest()]).flat()); + if (bounds.isValid()) { + leafletMap?.fitBounds(bounds, { padding: [50, 50] }); + } } function resetSelectedMarker() { diff --git a/components/Slider.vue b/components/Slider.vue index cd2592a..c58bae5 100644 --- a/components/Slider.vue +++ b/components/Slider.vue @@ -4,7 +4,7 @@ >
{{ group.year }}
-
- {{ t('selectedDate') }}: {{ props.selectedDate }} +
+ {{ t('selectedDate') }}: {{ selectedDate }}
@@ -41,16 +41,22 @@ import { useI18n } from 'vue-i18n'; const props = defineProps<{ - dateGroup: DateGroup[] - dateOptions: DateOptions[] - selectedIndex: number - selectedDate: string + dateOptions: string[] isSmallScreen: boolean }>(); -const emit = defineEmits<{ - (e: 'update:selectedIndex', value: number): void -}>(); +const modelValue = defineModel({ required: true }); + +const selectedDate = computed(() => { + if (props.dateOptions && props.dateOptions.length > 0) { + return props.dateOptions[modelValue.value]; + } + return ''; +}); + +const groupedDates = computed(() => { + return getDatesGroups(props.dateOptions); +}); const { t } = useI18n(); diff --git a/composables/dataTypes.ts b/composables/dataTypes.ts index e8761df..8571948 100644 --- a/composables/dataTypes.ts +++ b/composables/dataTypes.ts @@ -5,10 +5,6 @@ export interface DateGroup { color: string } -export interface DateOptions { - [key: number]: string -} - export interface DataEntry { [key: string]: string | number } @@ -28,8 +24,9 @@ export interface Options { legend_option: 'default' | 'colorVariant' type: string value_group: string - coordinate_field_x?: string - coordinate_field_y?: string + crs?: string | Record + latitude_field?: string | Record + longitude_field?: string | Record display_option: 'popup' | 'line chart' popup_name?: string popup_details?: { label: string, prop: string | string[] }[] diff --git a/composables/useSliderDates.ts b/composables/useSliderDates.ts index 0de24aa..19249c1 100644 --- a/composables/useSliderDates.ts +++ b/composables/useSliderDates.ts @@ -10,36 +10,30 @@ function isFeatureCollectionWithDate(item: GeoJSON.FeatureCollection): item is G return (item as GeoJSON.FeatureCollection & { date: string }).date !== undefined; } -export function getDatesGroups(fetchedData: GeoJSON.FeatureCollection[]) { - const groupedDates = computed(() => { - const yearGroups = new Map(); - fetchedData - .filter(isFeatureCollectionWithDate) - .forEach((item) => { - const year = item.date.slice(0, 4); - if (!yearGroups.has(year)) { - yearGroups.set(year, []); - } - yearGroups.get(year)!.push(item.date); - }); +export function getDatesGroups(dateOptions: string[]) { + const yearGroups = new Map(); + dateOptions.forEach((date) => { + const year = date.slice(0, 4); + if (!yearGroups.has(year)) { + yearGroups.set(year, []); + } + yearGroups.get(year)!.push(date); + }); - const total = Array.from(yearGroups.values()).reduce((acc, arr) => acc + arr.length, 0); - let offset = 0; + const total = Array.from(yearGroups.values()).reduce((acc, arr) => acc + arr.length, 0); + let offset = 0; - return Array.from(yearGroups.entries()).map(([year, dates], i) => { - const width = (dates.length / total) * 100; - const group = { - year, - width: width.toFixed(2), - offset: offset.toFixed(2), - color: yearColors[i % yearColors.length], - }; - offset += width; - return group; - }); + return Array.from(yearGroups.entries()).map(([year, dates], i) => { + const width = (dates.length / total) * 100; + const group = { + year, + width: width.toFixed(2), + offset: offset.toFixed(2), + color: yearColors[i % yearColors.length], + }; + offset += width; + return group; }); - - return groupedDates.value; } export function getDateOptions(fetchedData: GeoJSON.FeatureCollection[]): string[] { diff --git a/data/bathingWaterInputLayer.json b/data/bathingWaterInputLayer.json index f69a4f6..237768f 100644 --- a/data/bathingWaterInputLayer.json +++ b/data/bathingWaterInputLayer.json @@ -125,7 +125,7 @@ { "label": "SAISON", "prop": ["SAISONBEGINN", "SAISONENDE", "GESCHLOSSEN"] }, { "label": "INFRASTRUKTUR", "prop": "INFRASTRUKTUR" } ], - "coordinate_field_x": "GEOGR_BREITE", - "coordinate_field_y": "GEOGR_LAENGE" + "latitude_field": "GEOGR_BREITE", + "longitude_field": "GEOGR_LAENGE" } } diff --git a/data/mapDisplayOptions.json b/data/mapDisplayOptions.json index 638defa..417d9b5 100644 --- a/data/mapDisplayOptions.json +++ b/data/mapDisplayOptions.json @@ -2,6 +2,7 @@ "options": [ { "name": "bathing", "title": "Badegewässer" }, { "name": "lakes", "title": "Pegelstände" }, - { "name": "trees", "title": "Bäume Norderstedt" } + { "name": "trees", "title": "Bäume Norderstedt" }, + { "name": "windpower", "title": "Windkraftanlagen" } ] } diff --git a/data/windpowerInputLayer.json b/data/windpowerInputLayer.json new file mode 100644 index 0000000..fee7e15 --- /dev/null +++ b/data/windpowerInputLayer.json @@ -0,0 +1,59 @@ +{ + "datasets": [ + { + "host": "opendata.schleswig-holstein.de", + "id": "windkraftanlagen", + "title": "Windkraftanlagen" + } + ], + "mappings": [], + "options": + { + "label_option": "STATUS", + "legend_option": "default", + "legend_details": [ + { "label": "in Betrieb", "color": "#10B981" }, + { "label": "vor Inbetriebnahme", "color": "#0D9488" }, + { "label": "im Gen.Verf.", "color": "#3B82F6" } + ], + "type": "series", + "value_group": "", + "display_option": "popup", + "popup_name": "AKTENZEICHEN", + "popup_details": [ + { "label": "Kreis", "prop": "Kreis" }, + { "label": "Gemeinde", "prop": "Gemeinde" }, + { "label": "Typ", "prop": "Typ" }, + { "label": "Hersteller", "prop": "Hersteller" }, + { "label": "Nabenhöhe", "prop": "Nabenhöhe" }, + { "label": "Rotordurchmesser", "prop": "Rotordurchmesser" }, + { "label": "Genehmigungsdatum", "prop": "Genehmigungsdatum" }, + { "label": "Inbetriebnahme", "prop": "Inbetriebnahme" }, + { "label": "Datendatum", "prop": "Datendatum" } + ], + "crs": { + "windkraftanlagen": "EPSG:25832", + "windkraftanlagen/65f105e1-f7c0-48a1-9697-d77cef43d25c": "EPSG:4647", + "windkraftanlagen/12fb2027-d2d3-42c9-8774-34a70f584c0f": "EPSG:4647", + "windkraftanlagen/e1170052-286b-460e-9155-28b0bc44ae18": "EPSG:4647", + "windkraftanlagen/2c7d6758-e267-41ca-a68e-9dc9d5664ae8": "EPSG:4647", + "windkraftanlagen/7f93b5e0-d7be-48cb-9236-cffcfa12693a": "EPSG:4647", + "windkraftanlagen/f5287f83-47d1-459d-9440-00fd3154e108": "EPSG:4647", + "windkraftanlagen/9b4b5428-52d4-4676-a450-9ffb98b4670f": "EPSG:4647", + "windkraftanlagen/8623a709-4811-4c7f-b5d7-21203d24f11b": "EPSG:4647", + "windkraftanlagen/5fb10b92-97eb-4b2a-83d8-17cd17139ca5": "EPSG:4647" + }, + "latitude_field": { + "windkraftanlagen": "NORDWERT", + "windkraftanlagen/9b4b5428-52d4-4676-a450-9ffb98b4670f": "Nordwert", + "windkraftanlagen/8623a709-4811-4c7f-b5d7-21203d24f11b": "Nordwert", + "windkraftanlagen/5fb10b92-97eb-4b2a-83d8-17cd17139ca5": "AN_HW" + }, + "longitude_field": { + "windkraftanlagen": "OSTWERT", + "windkraftanlagen/9b4b5428-52d4-4676-a450-9ffb98b4670f": "Ostwert", + "windkraftanlagen/8623a709-4811-4c7f-b5d7-21203d24f11b": "Ostwert", + "windkraftanlagen/5fb10b92-97eb-4b2a-83d8-17cd17139ca5": "AN_RW" + } + } +} diff --git a/package.json b/package.json index 6a67ab0..332ec9f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", "nuxt": "3.19.0", - "proj4": "^2.15.0", + "proj4js-definitions": "^0.1.0", + "proj4leaflet": "^1.0.2", "shpjs": "^6.1.0", "tailwindcss": "4.1.5", "vue-chartjs": "^5.3.2", diff --git a/pages/index.vue b/pages/index.vue index 3285925..6882504 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -89,10 +89,8 @@ (() => { const selectedIndex = ref(0); const selectedItem = ref(null); const selectedDate = ref(); -let dateOptions: DateOptions[]; -let dateGroup: DateGroup[]; +let dateOptions: string[]; const loading = ref(false); const isDataSeries = ref(false); @@ -211,7 +208,6 @@ watch(feature, async (newval) => { if (isDataSeries.value) { seriesData.value = featureCollections; dateOptions = getDateOptions(seriesData.value); - dateGroup = getDatesGroups(seriesData.value); selectedIndex.value = dateOptions.length - 1; selectedDate.value = dateOptions[selectedIndex.value]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 079ec01..0da095d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,12 @@ importers: nuxt: specifier: 3.19.0 version: 3.19.0(@netlify/blobs@8.2.0)(@parcel/watcher@2.5.0)(@vue/compiler-sfc@3.5.21)(db0@0.3.2)(eslint@9.25.1(jiti@2.5.1))(ioredis@5.7.0)(lightningcss@1.30.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.50.2)(terser@5.37.0)(typescript@5.7.2)(vite@7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1))(vue-tsc@2.2.10(typescript@5.7.2))(yaml@2.8.1) - proj4: - specifier: ^2.15.0 - version: 2.15.0 + proj4js-definitions: + specifier: ^0.1.0 + version: 0.1.0 + proj4leaflet: + specifier: ^1.0.2 + version: 1.0.2 shpjs: specifier: ^6.1.0 version: 6.1.0 @@ -4799,6 +4802,12 @@ packages: proj4@2.15.0: resolution: {integrity: sha512-LqCNEcPdI03BrCHxPLj29vsd5afsm+0sV1H/O3nTDKrv8/LA01ea1z4QADDMjUqxSXWnrmmQDjqFm1J/uZ5RLw==} + proj4js-definitions@0.1.0: + resolution: {integrity: sha512-qmyhkWihIa3E3ZiFsNxvap6oS5+zEH85felbe/YVFuCr1E2j5yUrNQUOU9UEbvKkRJW9kk0MhENtbW3+9Nnr3g==} + + proj4leaflet@1.0.2: + resolution: {integrity: sha512-6GdDeUlhX/tHUiMEj80xQhlPjwrXcdfD0D5OBymY8WvxfbmZcdhNqQk7n7nFf53ue6QdP9ls9ZPjsAxnbZDTsw==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -5513,11 +5522,6 @@ packages: peerDependencies: vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 - vite-hot-client@2.0.4: - resolution: {integrity: sha512-W9LOGAyGMrbGArYJN4LBCdOC5+Zwh7dHvOHC0KmGKkJhsOzaKbpo/jEjpPKVHIW0/jBWj8RZG0NUxfgA8BxgAg==} - peerDependencies: - vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 - vite-hot-client@2.1.0: resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==} peerDependencies: @@ -5882,7 +5886,7 @@ snapshots: '@babel/types': 7.28.4 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.0 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5905,7 +5909,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.4 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.24.4 + browserslist: 4.26.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -6027,7 +6031,7 @@ snapshots: '@babel/parser': 7.28.4 '@babel/template': 7.27.2 '@babel/types': 7.28.4 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -6732,7 +6736,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/gen-mapping@0.3.8': @@ -6752,8 +6756,8 @@ snapshots: '@jridgewell/source-map@0.3.6': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/sourcemap-codec@1.5.0': {} @@ -6767,13 +6771,13 @@ snapshots: '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@kurkle/color@0.3.4': {} '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -6782,7 +6786,7 @@ snapshots: '@mapbox/node-pre-gyp@2.0.0': dependencies: consola: 3.4.2 - detect-libc: 2.0.3 + detect-libc: 2.1.0 https-proxy-agent: 7.0.6 node-fetch: 2.7.0 nopt: 8.0.0 @@ -6908,7 +6912,7 @@ snapshots: simple-git: 3.28.0 sirv: 3.0.1 structured-clone-es: 1.0.0 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 vite: 7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1) vite-plugin-inspect: 11.3.3(@nuxt/kit@3.19.0(magicast@0.3.5))(vite@7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1)) vite-plugin-vue-tracer: 1.0.0(vite@7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1))(vue@3.5.21(typescript@5.7.2)) @@ -7035,7 +7039,7 @@ snapshots: scule: 1.3.0 semver: 7.7.2 std-env: 3.9.0 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 ufo: 1.6.1 unctx: 2.4.1 unimport: 5.3.0 @@ -7581,10 +7585,10 @@ snapshots: '@rollup/pluginutils': 5.1.4(rollup@4.50.2) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.4.4(picomatch@4.0.2) + fdir: 6.5.0(picomatch@4.0.3) is-reference: 1.2.1 magic-string: 0.30.19 - picomatch: 4.0.2 + picomatch: 4.0.3 optionalDependencies: rollup: 4.50.2 @@ -8058,15 +8062,15 @@ snapshots: dependencies: '@mapbox/node-pre-gyp': 2.0.0 '@rollup/pluginutils': 5.1.4(rollup@4.50.2) - acorn: 8.14.1 - acorn-import-attributes: 1.9.5(acorn@8.14.1) + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) async-sema: 3.1.1 bindings: 1.5.0 estree-walker: 2.0.2 glob: 10.4.5 graceful-fs: 4.2.11 node-gyp-build: 4.8.4 - picomatch: 4.0.2 + picomatch: 4.0.3 resolve-from: 5.0.0 transitivePeerDependencies: - encoding @@ -8130,7 +8134,7 @@ snapshots: dependencies: '@vue/compiler-sfc': 3.5.21 ast-kit: 2.1.2 - local-pkg: 1.1.1 + local-pkg: 1.1.2 magic-string-ast: 1.0.2 unplugin-utils: 0.2.4 optionalDependencies: @@ -8239,7 +8243,7 @@ snapshots: mitt: 3.0.1 nanoid: 5.1.5 pathe: 2.0.3 - vite-hot-client: 2.0.4(vite@7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1)) + vite-hot-client: 2.1.0(vite@7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1)) vue: 3.5.21(typescript@5.7.2) transitivePeerDependencies: - vite @@ -8274,7 +8278,7 @@ snapshots: '@vue/language-core@3.0.7(typescript@5.7.2)': dependencies: '@volar/language-core': 2.4.23 - '@vue/compiler-dom': 3.5.13 + '@vue/compiler-dom': 3.5.21 '@vue/compiler-vue2': 2.7.16 '@vue/shared': 3.5.21 alien-signals: 2.0.7 @@ -8375,9 +8379,9 @@ snapshots: dependencies: event-target-shim: 5.0.1 - acorn-import-attributes@1.9.5(acorn@8.14.1): + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: - acorn: 8.14.1 + acorn: 8.15.0 acorn-jsx@5.3.2(acorn@8.14.1): dependencies: @@ -8475,8 +8479,8 @@ snapshots: autoprefixer@10.4.21(postcss@8.5.6): dependencies: - browserslist: 4.24.4 - caniuse-lite: 1.0.30001712 + browserslist: 4.26.2 + caniuse-lite: 1.0.30001743 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 @@ -8596,7 +8600,7 @@ snapshots: caniuse-api@3.0.0: dependencies: browserslist: 4.26.2 - caniuse-lite: 1.0.30001712 + caniuse-lite: 1.0.30001743 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 @@ -9512,7 +9516,7 @@ snapshots: externality@1.0.2: dependencies: - enhanced-resolve: 5.18.1 + enhanced-resolve: 5.18.3 mlly: 1.8.0 pathe: 1.1.2 ufo: 1.6.1 @@ -9754,7 +9758,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -9801,7 +9805,7 @@ snapshots: dependencies: '@ioredis/commands': 1.4.0 cluster-key-slot: 1.1.2 - debug: 4.4.0 + debug: 4.4.3 denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -9854,7 +9858,7 @@ snapshots: is-reference@1.2.1: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 is-ssh@1.4.0: dependencies: @@ -10012,7 +10016,7 @@ snapshots: citty: 0.1.6 clipboardy: 4.0.0 consola: 3.4.2 - crossws: 0.3.4 + crossws: 0.3.5 defu: 6.1.4 get-port-please: 3.2.0 h3: 1.15.4 @@ -10090,8 +10094,8 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 source-map-js: 1.2.1 markdown-table@3.0.4: {} @@ -10758,7 +10762,7 @@ snapshots: ofetch@1.4.1: dependencies: destr: 2.0.5 - node-fetch-native: 1.6.6 + node-fetch-native: 1.6.7 ufo: 1.6.1 ohash@2.0.11: {} @@ -11171,6 +11175,12 @@ snapshots: mgrs: 1.0.0 wkt-parser: 1.4.0 + proj4js-definitions@0.1.0: {} + + proj4leaflet@1.0.2: + dependencies: + proj4: 2.15.0 + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -11310,7 +11320,7 @@ snapshots: rollup-plugin-visualizer@6.0.3(rollup@4.50.2): dependencies: open: 8.4.2 - picomatch: 4.0.2 + picomatch: 4.0.3 source-map: 0.7.6 yargs: 17.7.2 optionalDependencies: @@ -11371,7 +11381,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.0 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -11424,7 +11434,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -11603,7 +11613,7 @@ snapshots: terser@5.37.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.1 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -11626,8 +11636,8 @@ snapshots: tinyglobby@0.2.14: dependencies: - fdir: 6.4.4(picomatch@4.0.2) - picomatch: 4.0.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 tinyglobby@0.2.15: dependencies: @@ -11851,14 +11861,14 @@ snapshots: ast-walker-scope: 0.8.2 chokidar: 4.0.3 json5: 2.2.3 - local-pkg: 1.1.1 + local-pkg: 1.1.2 magic-string: 0.30.19 mlly: 1.8.0 muggle-string: 0.4.1 pathe: 2.0.3 picomatch: 4.0.3 scule: 1.3.0 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 unplugin: 2.3.10 unplugin-utils: 0.2.4 yaml: 2.8.1 @@ -11981,10 +11991,6 @@ snapshots: vite: 7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1) vite-hot-client: 2.1.0(vite@7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1)) - vite-hot-client@2.0.4(vite@7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1)): - dependencies: - vite: 7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1) - vite-hot-client@2.1.0(vite@7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1)): dependencies: vite: 7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1) @@ -12019,7 +12025,7 @@ snapshots: picomatch: 4.0.3 strip-ansi: 7.1.0 tiny-invariant: 1.3.3 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 vite: 7.1.6(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.37.0)(yaml@2.8.1) vscode-uri: 3.1.0 optionalDependencies: diff --git a/server/api/fetchOpenData/index.get.ts b/server/api/fetchOpenData/index.get.ts index fb1b6a9..f334ead 100644 --- a/server/api/fetchOpenData/index.get.ts +++ b/server/api/fetchOpenData/index.get.ts @@ -18,7 +18,8 @@ export default defineEventHandler(async (event) => { } console.info('Fetching data for feature:', feature); // eslint-disable-line no-console try { - if (cache[feature] && (Date.now() - cache[feature].timestamp < CACHE_DURATION)) { + // eslint-disable-next-line node/prefer-global/process + if (process.env.NODE_ENV !== 'development' && cache[feature] && (Date.now() - cache[feature].timestamp < CACHE_DURATION)) { console.info('Returning cached data for feature:', feature); // eslint-disable-line no-console return cache[feature].data; } diff --git a/server/fetchData.ts b/server/fetchData.ts index 60887e6..779245c 100644 --- a/server/fetchData.ts +++ b/server/fetchData.ts @@ -1,12 +1,13 @@ import type { Package, Relationship, Response } from './types/ckan'; import type { Dataset, InputJSON } from '~/server/prepareInput'; import proj4 from 'proj4'; +import defs from 'proj4js-definitions'; import { fetchCsvFromUrl } from '~/server/utils/fetch-csv'; + import { fetchJsonFromUrl } from '~/server/utils/fetch-json'; import { fetchZipFromUrl } from '~/server/utils/fetch-zip'; -proj4.defs('EPSG:25832', '+proj=utm +zone=32 +ellps=GRS80 +units=m +no_defs'); -const fromProjection = 'EPSG:25832'; +proj4.defs(defs); const toProjection = 'WGS84'; const ALLOWED_HOSTS = [ @@ -16,7 +17,7 @@ const ALLOWED_HOSTS = [ 'hsi-sh.de', ]; -export interface FetchedData { id: string, date?: string, data: Record[] | GeoJSON.FeatureCollection } +export interface FetchedData { id: string, childId?: string, date?: string, data: Record[] | GeoJSON.FeatureCollection } export type FetchedDataArray = (GeoJSON.FeatureCollection & { date?: string })[]; @@ -31,7 +32,7 @@ export async function fetchSeriesData(s: Relationship, dataset: Dataset): Promis )?.url; if (resource) { const publishedDate = res.result.extras.find(m => m.key === 'issued')?.value || ''; - return { id: dataset.id, date: publishedDate, data: await fetchAndParseCsv(resource, dataset?.headers) }; + return { id: dataset.id, childId: s.__extras.subject_package_id, date: publishedDate, data: await fetchAndParseCsv(resource, dataset?.headers) }; } } } @@ -105,18 +106,23 @@ function isGeoJSON(data: Record | GeoJSON.Feature): data is GeoJ export async function fetchMappings(data: FetchedData[], datasets: InputJSON): Promise { try { - const baseDatasetId = datasets.mappings[0].source_db_id; let mappingDatasets: FetchedData[] = []; - if (datasets.options.type === 'series') { - // case: series → multiple snapshots - mappingDatasets = data.filter(d => d.id === baseDatasetId); + if (datasets.mappings.length === 0) { + mappingDatasets = data; } else { - const baseDataset = (data as FetchedData[]).find(d => d.id === baseDatasetId); - if (!baseDataset) - throw new Error('Base dataset not found'); - mappingDatasets = [baseDataset]; + const baseDatasetId = datasets.mappings[0].source_db_id; + if (datasets.options.type === 'series') { + // case: series → multiple snapshots + mappingDatasets = data.filter(d => d.id === baseDatasetId); + } + else { + const baseDataset = (data as FetchedData[]).find(d => d.id === baseDatasetId); + if (!baseDataset) + throw new Error('Base dataset not found'); + mappingDatasets = [baseDataset]; + } } const results = mappingDatasets.map((source) => { const baseRows = Array.isArray(source.data) ? source.data : source.data.features; @@ -184,13 +190,16 @@ export async function fetchMappings(data: FetchedData[], datasets: InputJSON): P }); // Always return as FeatureCollection - const features = merged.map((row) => { - const feature = isGeoJSON(row) ? row : csvToGeoJSONFromRow(row, datasets.options.coordinate_field_x, datasets.options.coordinate_field_y); + const id = source.childId ? [source.id, source.childId].join('/') : source.id; + const features = merged.map((row) => { + const latitudeField = typeof datasets.options.latitude_field === 'string' ? datasets.options.latitude_field : (datasets.options.latitude_field ? datasets.options.latitude_field[id] || datasets.options.latitude_field[source.id] : undefined); + const longitudeField = typeof datasets.options.longitude_field === 'string' ? datasets.options.longitude_field : (datasets.options.longitude_field ? datasets.options.longitude_field[id] || datasets.options.longitude_field[source.id] : undefined); + const feature = isGeoJSON(row) ? row : csvToGeoJSONFromRow(row, latitudeField, longitudeField); if (!feature) { - throw new Error('Invalid row, missing or invalid coordinates'); + return undefined; } return { ...feature, properties: { ...feature.properties, options: datasets.options } }; - }); + }).filter(feature => feature !== undefined); const featureCollection: GeoJSON.FeatureCollection & { date?: string } = { type: 'FeatureCollection', features, @@ -199,10 +208,17 @@ export async function fetchMappings(data: FetchedData[], datasets: InputJSON): P }), }; + if (datasets.options.crs) { + const crs = typeof datasets.options.crs === 'string' ? datasets.options.crs : (datasets.options.crs[id] || datasets.options.crs[source.id]); + if (crs) { + return reprojectGeoJSON(featureCollection, crs) as GeoJSON.FeatureCollection & { date?: string }; + } + } return featureCollection; }); - // Step 3: Normalize response wrapper - return results.sort((a, b) => (a.date || '').localeCompare(b.date || '')); + return results + .filter(fc => fc.features.length > 0) + .sort((a, b) => (a.date || '').localeCompare(b.date || '')); } catch (error) { console.error('Error fetching Mappings', error); @@ -223,6 +239,38 @@ function calculateMean(values: number[]): number { return sum / numbers.length; } +class InvalidSeparatorError extends Error {} + +function normalizeValue(value: string): string { + return value.replace(/^["']/, '').replace(/["']$/, '').trim(); +} +function splitCsvLine(headerLine: string, rows: string[], separator: string): Record[] | false { + const detectedHeaders = headerLine.split(separator).map(normalizeValue); + if (detectedHeaders.length < 2) { + return false; + } + + try { + return rows.map((line) => { + const values = line.split(separator).map(normalizeValue); + if (values.length !== detectedHeaders.length) { + console.error('Detected separator does not match number of columns in line compared to header', line, detectedHeaders, separator); + } + const entry: Record = {}; + detectedHeaders.forEach((key, i) => { + entry[key] = values[i] ?? ''; + }); + return entry; + }); + } + catch (error) { + if (error instanceof InvalidSeparatorError) { + return false; + } + throw error; + } +} + async function fetchAndParseCsv(csvUrl: string, headers?: string[]): Promise[]> { try { const response = await fetchCsvFromUrl(csvUrl); @@ -239,7 +287,7 @@ async function fetchAndParseCsv(csvUrl: string, headers?: string[]): Promise { - const values = line.split('|').map(v => v.replace(/^"|"$/g, '').trim()); + const values = line.split('|').map(normalizeValue); const entry: Record = {}; headers.forEach((key, i) => { entry[key] = values[i] ?? ''; @@ -252,16 +300,12 @@ async function fetchAndParseCsv(csvUrl: string, headers?: string[]): Promise v.replace(/^"|"$/g, '').trim()); - - return rows.map((line) => { - const values = line.split(';').map(v => v.replace(/^"|"$/g, '').trim()); - const entry: Record = {}; - detectedHeaders.forEach((key, i) => { - entry[key] = values[i] ?? ''; - }); - return entry; - }); + const result = splitCsvLine(headerLine, rows, ',') || splitCsvLine(headerLine, rows, ';') || splitCsvLine(headerLine, rows, '\t') || splitCsvLine(headerLine, rows, '|'); + if (!result) { + console.warn('Could not parse CSV with common separators , ; \\t |', csvUrl); + return []; + } + return result; } } catch (error) { @@ -270,11 +314,11 @@ async function fetchAndParseCsv(csvUrl: string, headers?: string[]): Promise(geoJsonUrl: string): Promise> { +async function fetchAndParseJson(geoJsonUrl: string): Promise { if (geoJsonUrl.toLowerCase().endsWith('.zip')) { const response = await fetchZipFromUrl(geoJsonUrl); const data = JSON.parse(new TextDecoder().decode(response)); - return data as GeoJSON.FeatureCollection; + return data as GeoJSON.FeatureCollection; } const response = await fetchJsonFromUrl(geoJsonUrl); // ✅ normalize to JS object @@ -300,19 +344,33 @@ async function fetchAndParseJson(geoJsonUrl: stri throw new Error('Could not find valid GeoJSON in response'); } - return reprojectGeoJSON(geojson); + if (geojson.crs && geojson.crs.properties && geojson.crs.properties.name) { + return reprojectGeoJSON(geojson as GeoJSON.FeatureCollection, geojson.crs.properties.name); + } + return geojson as GeoJSON.FeatureCollection; } -function reprojectGeoJSON(geojson: GeoJSON.FeatureCollection): GeoJSON.FeatureCollection { - return { - ...geojson, - features: geojson.features.map((feature) => { +function reprojectGeoJSON(geojson: GeoJSON.FeatureCollection, fromProjection: string): GeoJSON.FeatureCollection { + if (!fromProjection) { + return geojson; + } + if (fromProjection.startsWith('urn:')) { + fromProjection = fromProjection.replace('urn:ogc:def:crs:', '').replace('::', ':'); + } + if (fromProjection === toProjection) { + return geojson; + } + const reprojectedFeatures = geojson.features + .map((feature) => { if (feature.geometry.type !== 'Point') { return feature; } - const [x, y] = feature.geometry.coordinates; + + const [x, y] = feature.geometry.coordinates as [number, number]; const [lon, lat] = proj4(fromProjection, toProjection, [x, y]); + const [normLon, normLat] = normalizePoint([lon, lat]); + let newBbox = feature.bbox; if (feature.bbox && feature.bbox.length === 4) { const [minX, minY, maxX, maxY] = feature.bbox; @@ -325,11 +383,21 @@ function reprojectGeoJSON(geojson: GeoJSON.Featur ...feature, geometry: { ...feature.geometry, - coordinates: [lon, lat], + coordinates: [normLon, normLat], }, bbox: newBbox, }; - }), + }) + .filter((feature) => { + if (feature.geometry.type === 'Point') { + return isInsideGermany(feature.geometry.coordinates as [number, number]); + } + return true; + }); + + return { + ...geojson, + features: reprojectedFeatures, }; } @@ -410,3 +478,34 @@ export async function fetchUrlData(dataset: Dataset) { return null; } } + +function normalizePoint([x, y]: [number, number]): [number, number] { + // If it looks like lat/lon are swapped + if (y >= 5.9 && y <= 15.0 && x >= 47.2 && x <= 55.1) { + return [y, x]; // swap + } + return [x, y]; +} + +function isInsideGermany([lon, lat]: [number, number]) { + return lon >= 5.9 && lon <= 15.0 && lat >= 47.2 && lat <= 55.1; +} + +export async function normalizeFeatures(featureCollection: GeoJSON.FeatureCollection) { + const cleanedFeatures = featureCollection.features + .map((feature) => { + if (feature.geometry.type === 'Point') { + const coords = normalizePoint(feature.geometry.coordinates as [number, number]); + return { ...feature, geometry: { ...feature.geometry, coordinates: coords } }; + } + return feature; + }) + .filter((feature) => { + if (feature.geometry.type === 'Point') { + return isInsideGermany(feature.geometry.coordinates as [number, number]); + } + return true; + }); + + return { ...featureCollection, features: cleanedFeatures }; +} diff --git a/types/proj4js-definitions.d.ts b/types/proj4js-definitions.d.ts new file mode 100644 index 0000000..2731f80 --- /dev/null +++ b/types/proj4js-definitions.d.ts @@ -0,0 +1,4 @@ +declare module 'proj4js-definitions' { + const defs: [string, string][]; + export default defs; +}