diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index 31b8337..6922506 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -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; 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 {ticks.map(tick => { const y = 142 - (tick / ceiling) * 126; return {number.format(tick)}; })} - - + + {trend.startLabel} {trend.endLabel} ; diff --git a/src/lib/rounded-chart-path.ts b/src/lib/rounded-chart-path.ts new file mode 100644 index 0000000..ae50e76 --- /dev/null +++ b/src/lib/rounded-chart-path.ts @@ -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)}`; +} diff --git a/src/lib/social-milestone-image.tsx b/src/lib/social-milestone-image.tsx index 28cac26..0bece15 100644 --- a/src/lib/social-milestone-image.tsx +++ b/src/lib/social-milestone-image.tsx @@ -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 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 ; })} - - {points && } + + {path && }
{trend.startLabel || (german ? 'Erstellt' : 'Created')}