feat(windkarte): add per-area geo export + dynamic favicon
Deploy / verify (push) Successful in 1m1s
Deploy / deploy (push) Successful in 1m8s

- /api/vorranggebiete.geojson + .kml endpoints (filter via ?id=W-XX)
- Download-Buttons in WindAreaPanel pro selektiertes Gebiet
- Hover-Tooltip auf Polygon zeigt Fläche, geplante Anlagen, Höhe, Gemeinden
- Favicon + apple-touch-icon dynamisch aus data.logoUrl (CMS-Logo)
- alte windrad-PNGs in static/favicons/ entfernt
- WindkarteBlock list-fetch: _limit statt limit (RustyCMS-Param)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-20 12:14:58 +02:00
parent 61d2093d0b
commit a3f545d727
38 changed files with 220 additions and 80 deletions
-1
View File
@@ -3,7 +3,6 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+38
View File
@@ -45,6 +45,19 @@
if (!c) return null;
return `https://www.openstreetmap.org/?mlat=${c[0].toFixed(5)}&mlon=${c[1].toFixed(5)}#map=14/${c[0].toFixed(5)}/${c[1].toFixed(5)}`;
}
const exportInfo = $derived(
area
? [
`Gebiet ${area.gebiets_nr}${area.bezeichnung ? ` ${area.bezeichnung}` : ''}`,
area.flaeche_ha != null ? `Fläche: ${area.flaeche_ha.toFixed(1)} ha` : null,
area.anlagen_geplant != null ? `Geplante Anlagen: ${area.anlagen_geplant}` : null,
area.hoehe_max_m != null ? `Max. Nabenhöhe: ${area.hoehe_max_m} m` : null,
]
.filter(Boolean)
.join('\n')
: ''
);
</script>
{#if area}
@@ -155,6 +168,31 @@
</a>
{/if}
</div>
<!-- Export-Downloads -->
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">Geodaten-Export ({area.gebiets_nr})</p>
<div class="flex gap-2">
<a
href="/api/vorranggebiete.geojson?id={encodeURIComponent(area.gebiets_nr)}"
download
title={`GeoJSON herunterladen\n${exportInfo}`}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="mdi:download" class="size-3.5 shrink-0" />
GeoJSON
</a>
<a
href="/api/vorranggebiete.kml?id={encodeURIComponent(area.gebiets_nr)}"
download
title={`KML herunterladen\n${exportInfo}`}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="mdi:download" class="size-3.5 shrink-0" />
KML
</a>
</div>
</div>
</div>
</div>
{/if}
+16 -2
View File
@@ -257,8 +257,22 @@
});
// Hover tooltip
const hoverLabel = area.bezeichnung ?? p.name ?? "";
layer.bindTooltip(hoverLabel, { sticky: true, className: "windkarte-tooltip" });
const nrLabel = area.gebiets_nr ?? p.name ?? "";
const title = area.bezeichnung
? `${nrLabel} ${area.bezeichnung}`
: nrLabel;
const lines: string[] = [];
if (area.flaeche_ha != null) lines.push(`Fläche: ${area.flaeche_ha.toFixed(1)} ha`);
if (area.anlagen_geplant != null) lines.push(`Geplante Anlagen: ${area.anlagen_geplant}`);
if (area.hoehe_max_m != null) lines.push(`Max. Nabenhöhe: ${area.hoehe_max_m} m`);
if (area.gemeinden && area.gemeinden.length > 0) {
lines.push(`Gemeinden: ${area.gemeinden.slice(0, 3).join(', ')}${area.gemeinden.length > 3 ? '…' : ''}`);
}
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const tooltipHtml =
`<div class="font-semibold">${esc(title)}</div>` +
(lines.length ? `<div class="mt-0.5 text-xs opacity-90">${lines.map(esc).join('<br/>')}</div>` : '');
layer.bindTooltip(tooltipHtml, { sticky: true, className: "windkarte-tooltip" });
// Permanent label via divIcon marker at polygon center
const nr = area.gebiets_nr ?? p.name;
@@ -76,7 +76,7 @@ let areas = $state<WindArea[]>([]);
);
areas = results.filter((r): r is WindArea => r !== null);
} else {
const res = await fetch(`${CMS_BASE}/api/content/wind_area?limit=100`, {
const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=100`, {
signal: AbortSignal.timeout(6_000),
});
if (res.ok) {
+3 -3
View File
@@ -72,6 +72,9 @@
"map-outline": {
"body": "<path fill=\"currentColor\" d=\"m20.5 3l-.16.03L15 5.1L9 3L3.36 4.9c-.21.07-.36.25-.36.48V20.5a.5.5 0 0 0 .5.5l.16-.03L9 18.9l6 2.1l5.64-1.9c.21-.07.36-.25.36-.48V3.5a.5.5 0 0 0-.5-.5M10 5.47l4 1.4v11.66l-4-1.4zm-5 .99l3-1.01v11.7l-3 1.16zm14 11.08l-3 1.01V6.86l3-1.16z\"/>"
},
"download": {
"body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>"
},
"map-marker-outline": {
"body": "<path fill=\"currentColor\" d=\"M12 6.5A2.5 2.5 0 0 1 14.5 9a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 9.5 9A2.5 2.5 0 0 1 12 6.5M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7m0 2a5 5 0 0 0-5 5c0 1 0 3 5 9.71C17 12 17 10 17 9a5 5 0 0 0-5-5\"/>"
},
@@ -81,9 +84,6 @@
"file-download": {
"body": "<path fill=\"currentColor\" d=\"M14 2H6c-1.11 0-2 .89-2 2v16c0 1.11.89 2 2 2h12c1.11 0 2-.89 2-2V8zm-2 17l-4-4h2.5v-3h3v3H16zm1-10V3.5L18.5 9z\"/>"
},
"download": {
"body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>"
},
"google": {
"body": "<path fill=\"currentColor\" d=\"M21.35 11.1h-9.17v2.73h6.51c-.33 3.81-3.5 5.44-6.5 5.44C8.36 19.27 5 16.25 5 12c0-4.1 3.2-7.27 7.2-7.27c3.09 0 4.9 1.97 4.9 1.97L19 4.72S16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12c0 5.05 4.13 10 10.22 10c5.35 0 9.25-3.67 9.25-9.09c0-1.15-.15-1.81-.15-1.81\"/>"
},
+119
View File
@@ -0,0 +1,119 @@
import { env } from '$env/dynamic/public';
import type { WindArea } from '$lib/windkarte';
const CMS_BASE = () =>
(env.PUBLIC_CMS_URL || import.meta.env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(/\/$/, '');
export async function fetchAllWindAreas(): Promise<WindArea[]> {
const url = `${CMS_BASE()}/api/content/wind_area?_limit=500`;
const res = await fetch(url, { signal: AbortSignal.timeout(8_000) });
if (!res.ok) throw new Error(`CMS ${res.status}`);
const data = await res.json();
return (data.items ?? []) as WindArea[];
}
export function filterByGebietsNr(areas: WindArea[], id: string | null): WindArea[] {
if (!id) return areas;
return areas.filter((a) => a.gebiets_nr === id);
}
export function toGeoJSON(areas: WindArea[]): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: areas
.filter((a) => a.geometry)
.map((a) => ({
type: 'Feature' as const,
properties: {
id: a.gebiets_nr,
bezeichnung: a.bezeichnung ?? null,
status: a.status ?? 'rechtskraeftig',
flaeche_ha: a.flaeche_ha ?? null,
gemeinden: a.gemeinden ?? [],
anlagen_geplant: a.anlagen_geplant ?? null,
hoehe_max_m: a.hoehe_max_m ?? null,
investor: a.investor ?? null,
},
geometry: a.geometry!,
})),
};
}
// KML colors: AABBGGRR (alpha, blue, green, red)
const KML_STYLES: Record<string, { fill: string; line: string }> = {
rechtskraeftig: { fill: '7f457a2d', line: 'ff284d1a' },
entwurf_2: { fill: '7f528ab0', line: 'ff305568' },
entwurf_2_voraussichtlich: { fill: '7f2a75d4', line: 'ff184a8a' },
entwurf_3: { fill: '7f85706e', line: 'ff504126' },
};
function coordsToKml(ring: number[][]): string {
return ring.map(([lng, lat]) => `${lng},${lat},0`).join(' ');
}
function geometryToKml(geom: GeoJSON.Geometry): string {
if (geom.type === 'Polygon') {
const [outer, ...holes] = geom.coordinates;
return `<Polygon>
<outerBoundaryIs><LinearRing><coordinates>${coordsToKml(outer)}</coordinates></LinearRing></outerBoundaryIs>
${holes.map((h) => `<innerBoundaryIs><LinearRing><coordinates>${coordsToKml(h)}</coordinates></LinearRing></innerBoundaryIs>`).join('')}
</Polygon>`;
}
if (geom.type === 'MultiPolygon') {
return `<MultiGeometry>${geom.coordinates
.map(([outer, ...holes]) => `<Polygon>
<outerBoundaryIs><LinearRing><coordinates>${coordsToKml(outer)}</coordinates></LinearRing></outerBoundaryIs>
${holes.map((h) => `<innerBoundaryIs><LinearRing><coordinates>${coordsToKml(h)}</coordinates></LinearRing></innerBoundaryIs>`).join('')}
</Polygon>`)
.join('')}</MultiGeometry>`;
}
return '';
}
export function toKML(areas: WindArea[]): string {
const styleIds = Object.keys(KML_STYLES);
const styleXml = styleIds
.map((id) => {
const { fill, line } = KML_STYLES[id];
return `<Style id="${id}">
<LineStyle><color>${line}</color><width>1.5</width></LineStyle>
<PolyStyle><color>${fill}</color></PolyStyle>
</Style>`;
})
.join('\n ');
const placemarks = areas
.filter((a) => a.geometry)
.map((a) => {
const status = a.status ?? 'rechtskraeftig';
const desc = [
a.flaeche_ha != null ? `Fläche: ${a.flaeche_ha.toFixed(1)} ha` : null,
a.gemeinden?.length ? `Gemeinden: ${a.gemeinden.join(', ')}` : null,
`Status: ${status}`,
a.anlagen_geplant != null ? `Geplante Anlagen: ${a.anlagen_geplant}` : null,
]
.filter(Boolean)
.join('&#10;');
return `<Placemark>
<name>${esc(a.gebiets_nr)}${a.bezeichnung ? ` ${esc(a.bezeichnung)}` : ''}</name>
<description>${esc(desc)}</description>
<styleUrl>#${styleIds.includes(status) ? status : 'rechtskraeftig'}</styleUrl>
${geometryToKml(a.geometry!)}
</Placemark>`;
})
.join('\n ');
return `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Windvorranggebiete Thüringen</name>
${styleXml}
${placemarks}
</Document>
</kml>`;
}
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
+8 -6
View File
@@ -106,6 +106,11 @@
const breadcrumbItems = $derived(pageData?.breadcrumbItems ?? []);
const faviconUrl = $derived(data.logoUrl ?? '/favicon.svg');
const faviconType = $derived(
faviconUrl.split('?')[0].toLowerCase().endsWith('.svg') ? 'image/svg+xml' : 'image/png'
);
const robotsContent = $derived.by(() => {
const base = (pageData?.robots ?? 'index, follow').trim();
if (/noindex/i.test(base)) return base;
@@ -195,7 +200,7 @@
{#if isEmbed}
<!-- Embed-Modus: minimaler Head, kein OG/JSON-LD/Analytics. Page setzt
eigenen <title> und robots-meta. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type={faviconType} href={faviconUrl} />
{:else}
<meta name="theme-color" content="#ffffff" />
<meta name="robots" content={robotsContent} />
@@ -210,13 +215,10 @@
fetchpriority="high"
/>
{/if}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type={faviconType} href={faviconUrl} />
<link rel="apple-touch-icon" href={faviconUrl} />
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
<link rel="sitemap" href="/sitemap.xml" />
<link rel="icon" href="/favicons/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicons/android-icon-192x192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-icon-180x180.png" />
<title>{seoTitle}</title>
{#if seoDescription}
<meta name="description" content={seoDescription} />
@@ -0,0 +1,17 @@
import type { RequestHandler } from '@sveltejs/kit';
import { fetchAllWindAreas, filterByGebietsNr, toGeoJSON } from '$lib/wind-area-export';
export const GET: RequestHandler = async ({ url }) => {
const id = url.searchParams.get('id');
const areas = filterByGebietsNr(await fetchAllWindAreas(), id);
const body = JSON.stringify(toGeoJSON(areas), null, 2);
const filename = id ? `vorranggebiet-${id}.geojson` : 'vorranggebiete.geojson';
return new Response(body, {
headers: {
'Content-Type': 'application/geo+json; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
},
});
};
@@ -0,0 +1,17 @@
import type { RequestHandler } from '@sveltejs/kit';
import { fetchAllWindAreas, filterByGebietsNr, toKML } from '$lib/wind-area-export';
export const GET: RequestHandler = async ({ url }) => {
const id = url.searchParams.get('id');
const areas = filterByGebietsNr(await fetchAllWindAreas(), id);
const body = toKML(areas);
const filename = id ? `vorranggebiet-${id}.kml` : 'vorranggebiete.kml';
return new Response(body, {
headers: {
'Content-Type': 'application/vnd.google-earth.kml+xml; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
},
});
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/favicons/ms-icon-70x70.png" />
<square150x150logo src="/favicons/ms-icon-150x150.png" />
<square310x310logo src="/favicons/ms-icon-310x310.png" />
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

-46
View File
@@ -1,46 +0,0 @@
{
"name": "Windwiderstand",
"short_name": "Windwiderstand",
"start_url": "/",
"display": "browser",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/favicons/android-icon-36x36.png",
"sizes": "36x36",
"type": "image/png",
"density": "0.75"
},
{
"src": "/favicons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image/png",
"density": "1.0"
},
{
"src": "/favicons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"density": "1.5"
},
{
"src": "/favicons/android-icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"density": "2.0"
},
{
"src": "/favicons/android-icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"density": "3.0"
},
{
"src": "/favicons/android-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"density": "4.0"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

+1 -10
View File
@@ -1,10 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<path
d="M6 8l4 16 4-10 4 10 4-16"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M6 8l4 16 4-10 4 10 4-16" stroke="#000" stroke-width="2.5" fill="none"/></svg>

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 148 B