feat(windkarte): add per-area geo export + dynamic favicon
- /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>
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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) {
|
||||
|
||||
@@ -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\"/>"
|
||||
},
|
||||
|
||||
@@ -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(' ');
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
Before Width: | Height: | Size: 655 B |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -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>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
@@ -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 |