Polish social milestone chart curves

This commit is contained in:
2026-08-17 11:19:41 +02:00
parent eb932ebdaa
commit b278d275bb
3 changed files with 66 additions and 12 deletions

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/Button';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { roundedChartPath } from '@/lib/rounded-chart-path';
type Channel = 'x' | 'instagram'; type Channel = 'x' | 'instagram';
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null }; type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null };
@@ -24,20 +25,21 @@ function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: '
: Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4); : Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4);
const first = new Date(trend.points[0].at).getTime(); const first = new Date(trend.points[0].at).getTime();
const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1); const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1);
const points = trend.points.map(point => { const chartPoints = trend.points.map(point => {
const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410; const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410;
const y = 142 - (point.total / ceiling) * 126; const y = 142 - (point.total / ceiling) * 126;
return `${x},${y}`; return { x, y };
}).join(' '); });
const targetY = 142 - (trend.target / ceiling) * 126; const path = roundedChartPath(chartPoints, 18);
const endPoint = chartPoints[chartPoints.length - 1] || { x: 470, y: 142 };
const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 });
return <svg viewBox="0 0 480 184" className="w-full" role="img" aria-label="Cumulative unique scan trend"> return <svg viewBox="0 0 480 184" className="w-full" role="img" aria-label="Cumulative unique scan trend">
{ticks.map(tick => { {ticks.map(tick => {
const y = 142 - (tick / ceiling) * 126; const y = 142 - (tick / ceiling) * 126;
return <g key={tick}><text x="48" y={y + 4} textAnchor="end" fontSize="11" fontWeight={tick === trend.target ? 700 : 500} fill={tick === trend.target ? '#0256ff' : '#64748b'}>{number.format(tick)}</text><line x1="60" x2="470" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? 1.6 : 1} /></g>; return <g key={tick}><text x="48" y={y + 4} textAnchor="end" fontSize="11" fontWeight={tick === trend.target ? 700 : 500} fill={tick === trend.target ? '#0256ff' : '#64748b'}>{number.format(tick)}</text><line x1="60" x2="470" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? 1.6 : 1} /></g>;
})} })}
<polyline points={points} fill="none" stroke="#0256ff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" /> <path d={path} fill="none" stroke="#0256ff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
<circle cx="470" cy={targetY} r="4.5" fill="#ffffff" stroke="#0256ff" strokeWidth="3" /> <circle cx={endPoint.x} cy={endPoint.y} r="4.5" fill="#ffffff" stroke="#0256ff" strokeWidth="3" />
<text x="60" y="176" fontSize="11" fontWeight="600" fill="#45617f">{trend.startLabel}</text> <text x="60" y="176" fontSize="11" fontWeight="600" fill="#45617f">{trend.startLabel}</text>
<text x="470" y="176" textAnchor="end" fontSize="11" fontWeight="600" fill="#45617f">{trend.endLabel}</text> <text x="470" y="176" textAnchor="end" fontSize="11" fontWeight="600" fill="#45617f">{trend.endLabel}</text>
</svg>; </svg>;

View File

@@ -0,0 +1,50 @@
export type ChartPoint = { x: number; y: number };
function distance(a: ChartPoint, b: ChartPoint) {
return Math.hypot(b.x - a.x, b.y - a.y);
}
function pointTowards(from: ChartPoint, to: ChartPoint, amount: number): ChartPoint {
const length = distance(from, to);
if (length === 0) return from;
return {
x: from.x + ((to.x - from.x) / length) * amount,
y: from.y + ((to.y - from.y) / length) * amount,
};
}
function coordinate(value: number) {
return Number(value.toFixed(2));
}
/**
* Turns the factual scan points into one continuous SVG path while rounding
* only the visual corners. Source values and timestamps stay untouched; the
* path merely eases into and out of each factual turning point.
*/
export function roundedChartPath(points: ChartPoint[], cornerRadius: number): string {
if (points.length === 0) return '';
if (points.length === 1) return `M ${coordinate(points[0].x)} ${coordinate(points[0].y)}`;
let path = `M ${coordinate(points[0].x)} ${coordinate(points[0].y)}`;
for (let index = 1; index < points.length - 1; index += 1) {
const previous = points[index - 1];
const current = points[index];
const next = points[index + 1];
const radius = Math.min(
cornerRadius,
distance(previous, current) / 2,
distance(current, next) / 2,
);
const before = pointTowards(current, previous, radius);
const after = pointTowards(current, next, radius);
path += ` L ${coordinate(before.x)} ${coordinate(before.y)}`;
path += ` Q ${coordinate(current.x)} ${coordinate(current.y)} ${coordinate(after.x)} ${coordinate(after.y)}`;
}
const last = points[points.length - 1];
return `${path} L ${coordinate(last.x)} ${coordinate(last.y)}`;
}

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { roundedChartPath } from '@/lib/rounded-chart-path';
type Trend = { type Trend = {
points: Array<{ at: string; total: number }>; points: Array<{ at: string; total: number }>;
@@ -53,14 +54,15 @@ function chart(card: SocialMilestoneImageCard, german: boolean, box: { width: nu
const plotTop = 12; const plotTop = 12;
// Leaves room for the two date labels and the caption below the plot. // Leaves room for the two date labels and the caption below the plot.
const plotBottom = box.height - 67; const plotBottom = box.height - 67;
const points = rawPoints.map(point => { const chartPoints = rawPoints.map(point => {
const time = new Date(point.at).getTime(); const time = new Date(point.at).getTime();
const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft); const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft);
const y = plotBottom - Math.min(Math.max(Number(point.total) || 0, 0), ceiling) / ceiling * (plotBottom - plotTop); const y = plotBottom - Math.min(Math.max(Number(point.total) || 0, 0), ceiling) / ceiling * (plotBottom - plotTop);
return `${x},${y}`; return { x, y };
}).join(' '); });
const path = roundedChartPath(chartPoints, 30);
const number = new Intl.NumberFormat(german ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); const number = new Intl.NumberFormat(german ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 });
const endPoint = points.split(' ').at(-1)?.split(',').map(Number) || [plotRight, plotBottom]; const endPoint = chartPoints[chartPoints.length - 1] || { x: plotRight, y: plotBottom };
// Satori cannot render SVG <text> nodes in the deployed Node runtime. SVG // Satori cannot render SVG <text> nodes in the deployed Node runtime. SVG
// draws geometry only; the aligned labels are ordinary positioned text. // draws geometry only; the aligned labels are ordinary positioned text.
@@ -74,8 +76,8 @@ function chart(card: SocialMilestoneImageCard, german: boolean, box: { width: nu
</div>; </div>;
})} })}
<svg width={box.width} height={plotBottom + 12} viewBox={`0 0 ${box.width} ${plotBottom + 12}`} style={{ position: 'absolute', left: 0, top: 0 }}> <svg width={box.width} height={plotBottom + 12} viewBox={`0 0 ${box.width} ${plotBottom + 12}`} style={{ position: 'absolute', left: 0, top: 0 }}>
<polyline points={points} fill="none" stroke="#0256ff" strokeWidth="5" strokeLinejoin="round" strokeLinecap="round" /> <path d={path} fill="none" stroke="#0256ff" strokeWidth="5" strokeLinejoin="round" strokeLinecap="round" />
{points && <circle cx={endPoint[0]} cy={endPoint[1]} r="7" fill="white" stroke="#0256ff" strokeWidth="4" />} {path && <circle cx={endPoint.x} cy={endPoint.y} r="7" fill="white" stroke="#0256ff" strokeWidth="4" />}
</svg> </svg>
<div style={{ display: 'flex', position: 'absolute', left: plotLeft, right: 30, top: box.height - 53, justifyContent: 'space-between', color: '#45617f', fontSize: 16, fontWeight: 600 }}> <div style={{ display: 'flex', position: 'absolute', left: plotLeft, right: 30, top: box.height - 53, justifyContent: 'space-between', color: '#45617f', fontSize: 16, fontWeight: 600 }}>
<span>{trend.startLabel || (german ? 'Erstellt' : 'Created')}</span> <span>{trend.startLabel || (german ? 'Erstellt' : 'Created')}</span>