Polish social milestone chart curves
This commit is contained in:
@@ -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>;
|
||||||
|
|||||||
50
src/lib/rounded-chart-path.ts
Normal file
50
src/lib/rounded-chart-path.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user