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 { useTranslation } from '@/hooks/useTranslation';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { roundedChartPath } from '@/lib/rounded-chart-path';
|
||||
|
||||
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 };
|
||||
@@ -24,20 +25,21 @@ function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: '
|
||||
: Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4);
|
||||
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 points = trend.points.map(point => {
|
||||
const chartPoints = trend.points.map(point => {
|
||||
const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410;
|
||||
const y = 142 - (point.total / ceiling) * 126;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
const targetY = 142 - (trend.target / ceiling) * 126;
|
||||
return { x, y };
|
||||
});
|
||||
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 });
|
||||
return <svg viewBox="0 0 480 184" className="w-full" role="img" aria-label="Cumulative unique scan trend">
|
||||
{ticks.map(tick => {
|
||||
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>;
|
||||
})}
|
||||
<polyline points={points} 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" />
|
||||
<path d={path} fill="none" stroke="#0256ff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<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="470" y="176" textAnchor="end" fontSize="11" fontWeight="600" fill="#45617f">{trend.endLabel}</text>
|
||||
</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 { ImageResponse } from 'next/og';
|
||||
import { roundedChartPath } from '@/lib/rounded-chart-path';
|
||||
|
||||
type Trend = {
|
||||
points: Array<{ at: string; total: number }>;
|
||||
@@ -53,14 +54,15 @@ function chart(card: SocialMilestoneImageCard, german: boolean, box: { width: nu
|
||||
const plotTop = 12;
|
||||
// Leaves room for the two date labels and the caption below the plot.
|
||||
const plotBottom = box.height - 67;
|
||||
const points = rawPoints.map(point => {
|
||||
const chartPoints = rawPoints.map(point => {
|
||||
const time = new Date(point.at).getTime();
|
||||
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);
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return { x, y };
|
||||
});
|
||||
const path = roundedChartPath(chartPoints, 30);
|
||||
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
|
||||
// draws geometry only; the aligned labels are ordinary positioned text.
|
||||
@@ -74,8 +76,8 @@ function chart(card: SocialMilestoneImageCard, german: boolean, box: { width: nu
|
||||
</div>;
|
||||
})}
|
||||
<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" />
|
||||
{points && <circle cx={endPoint[0]} cy={endPoint[1]} r="7" fill="white" stroke="#0256ff" strokeWidth="4" />}
|
||||
<path d={path} fill="none" stroke="#0256ff" strokeWidth="5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
{path && <circle cx={endPoint.x} cy={endPoint.y} r="7" fill="white" stroke="#0256ff" strokeWidth="4" />}
|
||||
</svg>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user