Kanban-Archiv mit automatischem Löschen, Reise-Karte als Bild herunterladen

Kanban:
- Erledigte Aufgaben wandern 2 Tage nach "Erledigt" ins Archiv (Button
  "Archiv (n)" im Board, dort "Zurückholen") und werden nach 14 Tagen
  automatisch gelöscht (stündlich im Hintergrund und beim Öffnen)
- Neue Spalte done_at; schon erledigte Aufgaben zählen ab dem Update

Reisen:
- "Karte herunterladen": eigene Städte und besuchte Länder als PNG
  (4000/8000 px, nur besuchte Hauptstädte beschriftet) oder SVG (kleine
  Punkte, alle Städte beschriftet, beliebig zoombar); Ausschnitt "Meine
  Orte" oder ganze Welt; Beschriftungen weichen einander aus
- Hauptstadt-Kennzeichen (GeoNames PPLC) wird beim Hinzufügen gespeichert
  und für ältere Städte im Hintergrund bei Open-Meteo nachgetragen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:05:26 +02:00
1 parent f0e17b06b5
commit dc4ce18593
9 files changed
+489 -12

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
// Reise-Karte als Bild exportieren: eigene Städte und besuchte Länder in hoher Auflösung.
// Gezeichnet wird ein eigenes SVG (Farben aus dem aktiven Design); daraus wird bei Bedarf ein PNG.
// SVG: kleine Punkte und Schrift, alle Städte beschriftet (man kann beliebig hineinzoomen).
// PNG: größere Punkte, beschriftet nur besuchte Hauptstädte (sonst wird es im festen Raster zu voll).
import { geoCircle, geoGraticule10, geoNaturalEarth1, geoPath } from 'd3-geo';
const FONT = 'Arial, Helvetica, sans-serif'; // Systemschrift: Webfonts lädt ein SVG-Bild nicht nach
const MAX_PIXELS = 64e6; // Browser-Grenze für Canvas sicher unterschreiten
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const n = (v) => Math.round(v * 10) / 10;
function themeColors() {
const style = getComputedStyle(document.documentElement);
const get = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
const rgb = get('--accent-rgb', '255 70 70').split(/[\s,]+/).map(Number);
const accent = (a) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
return {
ocean: get('--map-ocean', '#050102'),
land: get('--map-land', '#1a0506'),
bg: get('--bg', '#080203'),
bright: get('--bright', '#ffffff'),
muted: get('--muted', '#999999'),
visited: accent(0.55),
visitedLine: accent(0.9),
line: accent(0.35),
grid: accent(0.1),
dot: accent(1),
};
}
// Beschriftungen nacheinander setzen: erst direkt neben den Punkt, sonst schrittweise weiter weg (mit Linie),
// bis sie weder andere Beschriftungen noch Punkte überdecken. Nur Punkte mit label werden beschriftet,
// alle Punkte bleiben aber Hindernisse.
function placeLabels(points, s, width, height) {
const ctx = document.createElement('canvas').getContext('2d');
const fs = 13 * s;
ctx.font = `600 ${fs}px ${FONT}`;
const r = 4.5 * s;
const h = fs * 1.25;
const gap = r + 3 * s;
const dirs = [[1, 0], [-1, 0], [1, -1], [1, 1], [-1, -1], [-1, 1], [0, -1], [0, 1]];
const steps = [1, 2.2, 3.8, 6, 9, 13, 18];
const placed = [];
const dots = points.map((p) => ({ x0: p.x - r, y0: p.y - r, x1: p.x + r, y1: p.y + r }));
const hits = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
// Größere Städte bekommen die besten Plätze
const order = points.filter((p) => p.label).sort((a, b) => (b.population || 0) - (a.population || 0));
return order.map((p) => {
const w = ctx.measureText(p.name).width + 2 * s;
let best = null;
search: for (const step of steps) {
for (const [dx, dy] of dirs) {
const len = Math.hypot(dx, dy);
const ax = p.x + (dx / len) * gap * step;
const ay = p.y + (dy / len) * gap * step;
const x0 = dx > 0 ? ax : dx < 0 ? ax - w : ax - w / 2;
const y0 = dy > 0 ? ay : dy < 0 ? ay - h : ay - h / 2;
const box = { x0, y0, x1: x0 + w, y1: y0 + h };
if (box.x0 < 0 || box.y0 < 0 || box.x1 > width || box.y1 > height) continue;
if (placed.some((b) => hits(box, b)) || dots.some((d) => hits(box, d))) continue;
best = { box, step };
break search;
}
}
// Kein freier Platz: rechts daneben, notfalls überlappend
if (!best) best = { box: { x0: p.x + gap, y0: p.y - h / 2, x1: p.x + gap + w, y1: p.y + h / 2 }, step: 1 };
placed.push(best.box);
return { ...p, ...best };
});
}
/**
* area: 'orte' (auf die eigenen Städte zoomen) oder 'welt'. width: Breite in Pixeln.
* marks: Größe von Punkten, Linien und Schrift (1 = gut lesbar im Ganzen, kleiner = erst beim Zoomen).
* label: 'alle' oder 'hauptstaedte' (welche Städte beschriftet werden). Liefert { svg, width, height }.
*/
export function buildMapSvg({ shapes, visited, cities, area, width, caption, marks = 1, label = 'alle' }) {
const page = width / 2400; // Maßstab für Ränder und Fußzeile
const s = page * marks; // Maßstab für Punkte, Linien und Schrift
const pad = Math.round(50 * page);
const footer = Math.round(46 * page);
const focus = area === 'orte' && cities.length
? { type: 'GeometryCollection', geometries: cities.map((c) => geoCircle().center([c.lon, c.lat]).radius(1.2)()) }
: { type: 'Sphere' };
// Höhe aus dem Seitenverhältnis des Ausschnitts, in vernünftigen Grenzen
const probe = geoNaturalEarth1().fitWidth(width - 2 * pad, focus);
const [[, py0], [, py1]] = geoPath(probe).bounds(focus);
let height = Math.round(Math.min(Math.max(py1 - py0, width * 0.3), width * 1.3) + 2 * pad + footer);
const projection = geoNaturalEarth1().fitExtent([[pad, pad], [width - pad, height - pad - footer]], focus);
const path = geoPath(projection);
const c = themeColors();
const points = cities
.map((city) => {
const p = projection([city.lon, city.lat]);
return p && {
name: city.name, population: city.population, x: p[0], y: p[1],
label: label === 'alle' || city.capital === true,
};
})
.filter((p) => p && p.x >= 0 && p.x <= width && p.y >= 0 && p.y <= height - footer);
const labels = placeLabels(points, s, width, height - footer);
const lw = n(0.6 * s);
const countries = shapes.map((shape) => {
const d = path(shape.feature);
if (!d) return '';
const on = visited.has(shape.key);
return `<path d="${d}" fill="${on ? c.visited : c.land}" stroke="${on ? c.visitedLine : c.line}" stroke-width="${lw}"/>`;
}).join('');
const r = n(4.5 * s);
const fs = n(13 * s);
const leaders = labels.filter((l) => l.step > 1).map((l) => {
// Linie vom Punkt zur nächsten Kante der Beschriftung
const tx = Math.max(l.box.x0, Math.min(l.x, l.box.x1));
const ty = Math.max(l.box.y0, Math.min(l.y, l.box.y1));
return `<line x1="${n(l.x)}" y1="${n(l.y)}" x2="${n(tx)}" y2="${n(ty)}" stroke="${c.bright}" stroke-opacity="0.7" stroke-width="${n(1 * s)}"/>`;
}).join('');
const dots = points.map((p) =>
`<circle cx="${n(p.x)}" cy="${n(p.y)}" r="${r}" fill="${c.bright}" stroke="${c.dot}" stroke-width="${n(2 * s)}"/>`).join('');
const texts = labels.map((l) =>
`<text x="${n(l.box.x0 + s)}" y="${n(l.box.y0 + fs * 0.98)}">${esc(l.name)}</text>`).join('');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="${c.bg}"/>
<path d="${path({ type: 'Sphere' })}" fill="${c.ocean}"/>
<path d="${path(geoGraticule10())}" fill="none" stroke="${c.grid}" stroke-width="${lw}"/>
<g stroke-linejoin="round">${countries}</g>
<g>${leaders}</g>
<g>${dots}</g>
<g font-family="${FONT}" font-size="${fs}" font-weight="600" fill="${c.bright}" stroke="${c.bg}" stroke-width="${n(3.2 * s)}" stroke-linejoin="round" paint-order="stroke">${texts}</g>
<text x="${pad}" y="${height - Math.round(18 * page)}" font-family="${FONT}" font-size="${n(15 * page)}" fill="${c.muted}">${esc(caption)}</text>
</svg>`;
return { svg, width, height };
}
/** Wie buildMapSvg, aber fürs PNG so verkleinert, dass der Browser das Bild noch zeichnen kann. */
export function buildMapImage(options) {
const out = buildMapSvg(options);
if (out.width * out.height <= MAX_PIXELS) return out;
return buildMapSvg({ ...options, width: Math.floor(Math.sqrt(MAX_PIXELS * (out.width / out.height))) });
}
export async function svgToPng(svg, width, height) {
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
try {
const img = new Image();
img.src = url;
await img.decode();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
return await new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('Bild zu groß für diesen Browser.'))), 'image/png');
});
} finally {
URL.revokeObjectURL(url);
}
}
export function download(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}