loop-benchmarking

Controlled experiments across agentic coding configurations. Same task, one variable, what actually works.
git clone https://git.shiptheloop.com/loop-benchmarking.git
Log | Files | Refs | README

commit 4bb4cb64ef5efb430c6a2757632ee77720e7b741
parent f87935500650d6e4b49041f7096eadae319c1515
Author: Brian Graham <brian@buildingbetterteams.de>
Date:   Mon,  6 Apr 2026 20:18:04 +0200

Box plots for grid charts, model toggles for scatter hulls

- Average Score by Model: replaced bar+error with box plot (Q1/median/Q3/whiskers)
- Pass Rate by Task: same box plot conversion
- Scatter plots: toggle buttons to show/hide individual models
  Axis scales stay fixed when models are hidden

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Mdashboard/src/components/Charts.tsx | 396+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mdashboard/src/components/ScatterPlot.tsx | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
2 files changed, 353 insertions(+), 115 deletions(-)

diff --git a/dashboard/src/components/Charts.tsx b/dashboard/src/components/Charts.tsx @@ -1,6 +1,7 @@ import { - BarChart, + ComposedChart, Bar, + Scatter, XAxis, YAxis, CartesianGrid, @@ -8,7 +9,7 @@ import { ResponsiveContainer, Legend, Cell, - ErrorBar, + ZAxis, } from "recharts"; import type { Run } from "../lib/types"; @@ -16,26 +17,41 @@ interface ChartsProps { runs: Run[]; } -interface ModelScore { - model: string; - avg_score: number; - min_score: number; - max_score: number; - errorRange: [number, number]; - avg_cost: number; +interface BoxPlotData { + label: string; + min: number; + q1: number; + median: number; + q3: number; + max: number; cellCount: number; + scores: number[]; + // Derived fields for recharts stacked bar trick + base: number; // invisible bar height = q1 + iqr: number; // visible box height = q3 - q1 + color: string; } -interface TaskScore { - task: string; - avg_score: number; - min_score: number; - max_score: number; - scoreErrorRange: [number, number]; - pass_rate: number; - min_pass_rate: number; - max_pass_rate: number; - passRateErrorRange: [number, number]; +interface TaskBoxPlotData { + label: string; + // Score distribution + sMin: number; + sQ1: number; + sMedian: number; + sQ3: number; + sMax: number; + sBase: number; + sIqr: number; + sScores: number[]; + // Pass rate distribution + pMin: number; + pQ1: number; + pMedian: number; + pQ3: number; + pMax: number; + pBase: number; + pIqr: number; + pScores: number[]; cellCount: number; } @@ -132,7 +148,30 @@ function aggregateCells(runs: Run[]): CellAggregate[] { })); } -function aggregateByModel(runs: Run[]): ModelScore[] { +const MODEL_ORDER: Record<string, number> = { haiku: 1, sonnet: 2, opus: 3 }; + +function quantile(sorted: number[], q: number): number { + if (sorted.length === 0) return 0; + if (sorted.length === 1) return sorted[0]; + const pos = q * (sorted.length - 1); + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + const frac = pos - lo; + return sorted[lo] + frac * (sorted[hi] - sorted[lo]); +} + +function computeBoxStats(values: number[]): { min: number; q1: number; median: number; q3: number; max: number } { + const sorted = [...values].sort((a, b) => a - b); + return { + min: sorted.length > 0 ? sorted[0] : 0, + q1: quantile(sorted, 0.25), + median: quantile(sorted, 0.5), + q3: quantile(sorted, 0.75), + max: sorted.length > 0 ? sorted[sorted.length - 1] : 0, + }; +} + +function aggregateByModel(runs: Run[]): BoxPlotData[] { const cells = aggregateCells(runs); const byModel: Record<string, CellAggregate[]> = {}; @@ -141,36 +180,27 @@ function aggregateByModel(runs: Run[]): ModelScore[] { byModel[cell.model].push(cell); } - const MODEL_ORDER: Record<string, number> = { haiku: 1, sonnet: 2, opus: 3 }; const sortedEntries = Object.entries(byModel).sort(([a], [b]) => - (MODEL_ORDER[a] || 99) - (MODEL_ORDER[b] || 99) + (MODEL_ORDER[a] || 99) - (MODEL_ORDER[b] || 99) || a.localeCompare(b) ); return sortedEntries.map(([model, modelCells]) => { const scores = modelCells.map((c) => Math.round(c.avgScore * 100)); - const costs = modelCells.map((c) => c.avgCost); - const avgScore = scores.length > 0 - ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) - : 0; - const minScore = scores.length > 0 ? Math.min(...scores) : 0; - const maxScore = scores.length > 0 ? Math.max(...scores) : 0; - const avgCost = costs.length > 0 - ? Math.round((costs.reduce((a, b) => a + b, 0) / costs.length) * 100) / 100 - : 0; - + const stats = computeBoxStats(scores); + const baseModel = model; return { - model: `${model} (n=${modelCells.length} cells)`, - avg_score: avgScore, - min_score: minScore, - max_score: maxScore, - errorRange: [avgScore - minScore, maxScore - avgScore] as [number, number], - avg_cost: avgCost, + label: `${model} (n=${modelCells.length})`, + ...stats, + base: stats.q1, + iqr: stats.q3 - stats.q1, cellCount: modelCells.length, + scores, + color: MODEL_COLORS[baseModel] || SMUI.frost2, }; }); } -function aggregateByTask(runs: Run[]): TaskScore[] { +function aggregateByTask(runs: Run[]): TaskBoxPlotData[] { const cells = aggregateCells(runs); const byTask: Record<string, CellAggregate[]> = {}; @@ -182,34 +212,170 @@ function aggregateByTask(runs: Run[]): TaskScore[] { return Object.entries(byTask).map(([task, taskCells]) => { const scores = taskCells.map((c) => Math.round(c.avgScore * 100)); const passRates = taskCells.map((c) => Math.round(c.passRate * 100)); - - const avgScore = scores.length > 0 - ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) - : 0; - const minScore = scores.length > 0 ? Math.min(...scores) : 0; - const maxScore = scores.length > 0 ? Math.max(...scores) : 0; - - const avgPassRate = passRates.length > 0 - ? Math.round(passRates.reduce((a, b) => a + b, 0) / passRates.length) - : 0; - const minPassRate = passRates.length > 0 ? Math.min(...passRates) : 0; - const maxPassRate = passRates.length > 0 ? Math.max(...passRates) : 0; + const sStats = computeBoxStats(scores); + const pStats = computeBoxStats(passRates); return { - task: `${task} (n=${taskCells.length} cells)`, - avg_score: avgScore, - min_score: minScore, - max_score: maxScore, - scoreErrorRange: [avgScore - minScore, maxScore - avgScore] as [number, number], - pass_rate: avgPassRate, - min_pass_rate: minPassRate, - max_pass_rate: maxPassRate, - passRateErrorRange: [avgPassRate - minPassRate, maxPassRate - avgPassRate] as [number, number], + label: `${task} (n=${taskCells.length})`, + sMin: sStats.min, sQ1: sStats.q1, sMedian: sStats.median, sQ3: sStats.q3, sMax: sStats.max, + sBase: sStats.q1, sIqr: sStats.q3 - sStats.q1, + sScores: scores, + pMin: pStats.min, pQ1: pStats.q1, pMedian: pStats.median, pQ3: pStats.q3, pMax: pStats.max, + pBase: pStats.q1, pIqr: pStats.q3 - pStats.q1, + pScores: passRates, cellCount: taskCells.length, }; }); } +// Custom shape: draws a box from q1 to q3 with whiskers from min to max and a median line +function BoxPlotShape(props: any) { + const { x, y, width, height, payload } = props as { + x: number; y: number; width: number; height: number; + payload: BoxPlotData; + }; + if (!payload || height === undefined) return null; + + const { min, median, max, color } = payload; + // The bar is rendered from q1 (base) with height iqr (q3-q1). + // y is the top of the bar (q3 in chart coords), y+height is the bottom (q1). + const boxTop = y; + const boxBottom = y + height; + const boxQ3 = payload.q3; + const boxQ1 = payload.q1; + const centerX = x + width / 2; + + // Scale: we need to convert data values to pixel positions. + // We know q1 maps to boxBottom and q3 maps to boxTop. + const dataToY = (val: number): number => { + if (boxQ3 === boxQ1) return boxTop; + return boxTop + ((boxQ3 - val) / (boxQ3 - boxQ1)) * (boxBottom - boxTop); + }; + + const minY = dataToY(min); + const maxY = dataToY(max); + const medianY = dataToY(median); + const whiskerHalfW = width * 0.3; + + return ( + <g> + {/* Whisker line: min to max */} + <line x1={centerX} y1={minY} x2={centerX} y2={maxY} stroke={SMUI.muted} strokeWidth={1} /> + {/* Min whisker cap */} + <line x1={centerX - whiskerHalfW} y1={minY} x2={centerX + whiskerHalfW} y2={minY} stroke={SMUI.muted} strokeWidth={1} /> + {/* Max whisker cap */} + <line x1={centerX - whiskerHalfW} y1={maxY} x2={centerX + whiskerHalfW} y2={maxY} stroke={SMUI.muted} strokeWidth={1} /> + {/* Box (IQR) */} + <rect x={x} y={boxTop} width={width} height={Math.max(height, 1)} fill={color} fillOpacity={0.3} stroke={color} strokeWidth={1} /> + {/* Median line */} + <line x1={x} y1={medianY} x2={x + width} y2={medianY} stroke={color} strokeWidth={2} /> + </g> + ); +} + +// Custom shape for task box plots (score or pass rate) +function TaskBoxPlotShape(prefix: "s" | "p", color: string) { + return function Shape(props: any) { + const { x, y, width, height, payload } = props as { + x: number; y: number; width: number; height: number; + payload: TaskBoxPlotData; + }; + if (!payload || height === undefined) return null; + + const min = payload[`${prefix}Min`] as number; + const q1 = payload[`${prefix}Q1`] as number; + const q3 = payload[`${prefix}Q3`] as number; + const median = payload[`${prefix}Median`] as number; + const max = payload[`${prefix}Max`] as number; + + const boxTop = y; + const boxBottom = y + height; + const centerX = x + width / 2; + + const dataToY = (val: number): number => { + if (q3 === q1) return boxTop; + return boxTop + ((q3 - val) / (q3 - q1)) * (boxBottom - boxTop); + }; + + const minY = dataToY(min); + const maxY = dataToY(max); + const medianY = dataToY(median); + const whiskerHalfW = width * 0.3; + + return ( + <g> + <line x1={centerX} y1={minY} x2={centerX} y2={maxY} stroke={SMUI.muted} strokeWidth={1} /> + <line x1={centerX - whiskerHalfW} y1={minY} x2={centerX + whiskerHalfW} y2={minY} stroke={SMUI.muted} strokeWidth={1} /> + <line x1={centerX - whiskerHalfW} y1={maxY} x2={centerX + whiskerHalfW} y2={maxY} stroke={SMUI.muted} strokeWidth={1} /> + <rect x={x} y={boxTop} width={width} height={Math.max(height, 1)} fill={color} fillOpacity={0.3} stroke={color} strokeWidth={1} /> + <line x1={x} y1={medianY} x2={x + width} y2={medianY} stroke={color} strokeWidth={2} /> + </g> + ); + }; +} + +// Build scatter data for individual cell dots on model chart +function modelScatterData(data: BoxPlotData[]): Array<{ label: string; score: number; color: string }> { + const points: Array<{ label: string; score: number; color: string }> = []; + for (const d of data) { + for (const s of d.scores) { + points.push({ label: d.label, score: s, color: d.color }); + } + } + return points; +} + +// Build scatter data for task chart +function taskScatterData(data: TaskBoxPlotData[], prefix: "s" | "p", color: string): Array<{ label: string; value: number; color: string }> { + const key = `${prefix}Scores` as "sScores" | "pScores"; + const points: Array<{ label: string; value: number; color: string }> = []; + for (const d of data) { + for (const s of d[key]) { + points.push({ label: d.label, value: s, color }); + } + } + return points; +} + +// Custom tooltip for model box plot +function ModelBoxTooltipContent({ active, payload, label }: { active?: boolean; payload?: Array<{ payload: BoxPlotData }>; label?: string }) { + if (!active || !payload || payload.length === 0) return null; + const d = payload[0].payload; + return ( + <div style={TOOLTIP_STYLE}> + <div style={{ marginBottom: 4, fontWeight: 600 }}>{label}</div> + <div>Max: {d.max}%</div> + <div>Q3: {Math.round(d.q3)}%</div> + <div>Median: {Math.round(d.median)}%</div> + <div>Q1: {Math.round(d.q1)}%</div> + <div>Min: {d.min}%</div> + </div> + ); +} + +// Custom tooltip for task box plot +function TaskBoxTooltipContent({ active, payload, label }: { active?: boolean; payload?: Array<{ payload: TaskBoxPlotData }>; label?: string }) { + if (!active || !payload || payload.length === 0) return null; + const d = payload[0].payload; + return ( + <div style={TOOLTIP_STYLE}> + <div style={{ marginBottom: 4, fontWeight: 600 }}>{label}</div> + <div style={{ marginBottom: 4 }}> + <div style={{ color: SMUI.frost2, fontWeight: 600 }}>Score</div> + <div>Max: {d.sMax}% / Q3: {Math.round(d.sQ3)}%</div> + <div>Median: {Math.round(d.sMedian)}%</div> + <div>Q1: {Math.round(d.sQ1)}% / Min: {d.sMin}%</div> + </div> + <div> + <div style={{ color: SMUI.green, fontWeight: 600 }}>Pass Rate</div> + <div>Max: {d.pMax}% / Q3: {Math.round(d.pQ3)}%</div> + <div>Median: {Math.round(d.pMedian)}%</div> + <div>Q1: {Math.round(d.pQ1)}% / Min: {d.pMin}%</div> + </div> + </div> + ); +} + export default function Charts({ runs }: ChartsProps) { if (runs.length === 0) { return ( @@ -221,16 +387,19 @@ export default function Charts({ runs }: ChartsProps) { const modelData = aggregateByModel(runs); const taskData = aggregateByTask(runs); + const modelDots = modelScatterData(modelData); + const taskScoreDots = taskScatterData(taskData, "s", SMUI.frost2); + const taskPassDots = taskScatterData(taskData, "p", SMUI.green); return ( <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}> <div className="card"> - <h3 style={{ marginBottom: "16px" }}>Average Score by Model</h3> + <h3 style={{ marginBottom: "16px" }}>Score Distribution by Model</h3> <ResponsiveContainer width="100%" height={250}> - <BarChart data={modelData} barCategoryGap="20%"> + <ComposedChart data={modelData} barCategoryGap="20%"> <CartesianGrid strokeDasharray="3 3" stroke={SMUI.border} vertical={false} /> <XAxis - dataKey="model" + dataKey="label" stroke={SMUI.muted} fontSize={11} fontFamily="'JetBrains Mono', monospace" @@ -244,34 +413,41 @@ export default function Charts({ runs }: ChartsProps) { domain={[0, 100]} tickLine={false} axisLine={false} + yAxisId="score" /> - <Tooltip - contentStyle={TOOLTIP_STYLE} - cursor={{ fill: "hsl(217 17% 28% / 0.3)" }} - formatter={(value: number, name: string) => { - if (name === "Avg Score %") return [`${value}%`, name]; - return [value, name]; - }} - labelFormatter={(label: string) => label} - /> - <Bar dataKey="avg_score" name="Avg Score %" radius={0}> - <ErrorBar dataKey="errorRange" stroke={SMUI.muted} strokeWidth={1.5} width={6} /> - {modelData.map((entry) => { - const baseModel = entry.model.split(" ")[0]; - return <Cell key={entry.model} fill={MODEL_COLORS[baseModel] || SMUI.frost2} />; - })} + <Tooltip content={<ModelBoxTooltipContent />} cursor={{ fill: "hsl(217 17% 28% / 0.3)" }} /> + {/* Invisible base bar to push the visible box up to q1 */} + <Bar dataKey="base" stackId="box" fill="transparent" barSize={40} yAxisId="score" /> + {/* Visible IQR box with custom shape for whiskers and median */} + <Bar dataKey="iqr" stackId="box" barSize={40} yAxisId="score" shape={<BoxPlotShape />}> + {modelData.map((entry) => ( + <Cell key={entry.label} fill={entry.color} /> + ))} </Bar> - </BarChart> + {/* Individual cell score dots */} + <Scatter + data={modelDots} + dataKey="score" + yAxisId="score" + fill={SMUI.frost2} + fillOpacity={0.5} + > + <ZAxis range={[20, 20]} /> + {modelDots.map((dot, i) => ( + <Cell key={i} fill={dot.color} fillOpacity={0.5} /> + ))} + </Scatter> + </ComposedChart> </ResponsiveContainer> </div> <div className="card"> - <h3 style={{ marginBottom: "16px" }}>Pass Rate by Task</h3> + <h3 style={{ marginBottom: "16px" }}>Score & Pass Rate Distribution by Task</h3> <ResponsiveContainer width="100%" height={250}> - <BarChart data={taskData} barCategoryGap="20%"> + <ComposedChart data={taskData} barCategoryGap="20%"> <CartesianGrid strokeDasharray="3 3" stroke={SMUI.border} vertical={false} /> <XAxis - dataKey="task" + dataKey="label" stroke={SMUI.muted} fontSize={11} fontFamily="'JetBrains Mono', monospace" @@ -285,15 +461,9 @@ export default function Charts({ runs }: ChartsProps) { domain={[0, 100]} tickLine={false} axisLine={false} + yAxisId="score" /> - <Tooltip - contentStyle={TOOLTIP_STYLE} - cursor={{ fill: "hsl(217 17% 28% / 0.3)" }} - formatter={(value: number, name: string) => { - return [`${value}%`, name]; - }} - labelFormatter={(label: string) => label} - /> + <Tooltip content={<TaskBoxTooltipContent />} cursor={{ fill: "hsl(217 17% 28% / 0.3)" }} /> <Legend wrapperStyle={{ fontFamily: "'JetBrains Mono', monospace", @@ -301,14 +471,46 @@ export default function Charts({ runs }: ChartsProps) { textTransform: "uppercase", letterSpacing: "0.5px", }} + payload={[ + { value: "Score %", type: "rect", color: SMUI.frost2 }, + { value: "Pass Rate %", type: "rect", color: SMUI.green }, + ]} /> - <Bar dataKey="avg_score" fill={SMUI.frost2} name="Avg Score %" radius={0}> - <ErrorBar dataKey="scoreErrorRange" stroke={SMUI.muted} strokeWidth={1.5} width={6} /> - </Bar> - <Bar dataKey="pass_rate" fill={SMUI.green} name="Pass Rate %" radius={0}> - <ErrorBar dataKey="passRateErrorRange" stroke={SMUI.muted} strokeWidth={1.5} width={6} /> - </Bar> - </BarChart> + {/* Score box plot */} + <Bar dataKey="sBase" stackId="scoreBox" fill="transparent" barSize={30} yAxisId="score" name=" " legendType="none" /> + <Bar dataKey="sIqr" stackId="scoreBox" barSize={30} yAxisId="score" name="Score %" shape={TaskBoxPlotShape("s", SMUI.frost2)} fill={SMUI.frost2} /> + {/* Pass rate box plot */} + <Bar dataKey="pBase" stackId="passBox" fill="transparent" barSize={30} yAxisId="score" name=" " legendType="none" /> + <Bar dataKey="pIqr" stackId="passBox" barSize={30} yAxisId="score" name="Pass Rate %" shape={TaskBoxPlotShape("p", SMUI.green)} fill={SMUI.green} /> + {/* Individual score dots */} + <Scatter + data={taskScoreDots} + dataKey="value" + yAxisId="score" + fill={SMUI.frost2} + fillOpacity={0.5} + legendType="none" + > + <ZAxis range={[15, 15]} /> + {taskScoreDots.map((_, i) => ( + <Cell key={i} fill={SMUI.frost2} fillOpacity={0.5} /> + ))} + </Scatter> + {/* Individual pass rate dots */} + <Scatter + data={taskPassDots} + dataKey="value" + yAxisId="score" + fill={SMUI.green} + fillOpacity={0.5} + legendType="none" + > + <ZAxis range={[15, 15]} /> + {taskPassDots.map((_, i) => ( + <Cell key={i} fill={SMUI.green} fillOpacity={0.5} /> + ))} + </Scatter> + </ComposedChart> </ResponsiveContainer> </div> </div> diff --git a/dashboard/src/components/ScatterPlot.tsx b/dashboard/src/components/ScatterPlot.tsx @@ -368,6 +368,7 @@ export default function ScatterPlot({ }: ScatterPlotProps) { const [xMetric, setXMetric] = React.useState(defaultX); const [yMetric, setYMetric] = React.useState(defaultY); + const [visibleModels, setVisibleModels] = React.useState<Set<string> | null>(null); const xConf = METRIC_CONFIG[xMetric]; const yConf = METRIC_CONFIG[yMetric]; @@ -393,7 +394,33 @@ export default function ScatterPlot({ totalCells++; } - const regions = computeRegions(byModel); + // All models present in data (stable order) + const MODEL_ORDER: Record<string, number> = { haiku: 1, sonnet: 2, opus: 3 }; + const allModels = Object.keys(byModel).sort( + (a, b) => (MODEL_ORDER[a] || 99) - (MODEL_ORDER[b] || 99) + ); + + // Initialize visibleModels on first render or when models change + const effectiveVisible = visibleModels ?? new Set(allModels); + + const toggleModel = (model: string) => { + setVisibleModels((prev) => { + const current = prev ?? new Set(allModels); + const next = new Set(current); + if (next.has(model)) { + next.delete(model); + } else { + next.add(model); + } + return next; + }); + }; + + // Compute regions from ALL data (for stable axis domains) + const allRegions = computeRegions(byModel); + + // Filter to visible models for rendering + const regions = allRegions.filter((r) => effectiveVisible.has(r.model)); const centroids: CentroidDatum[] = regions.map((r) => ({ model: r.model, @@ -408,9 +435,9 @@ export default function ScatterPlot({ const [hovered, setHovered] = React.useState<CentroidDatum | null>(null); - // Compute axis domains with padding - const allX = regions.flatMap((r) => r.points.map((p) => p[0])); - const allY = regions.flatMap((r) => r.points.map((p) => p[1])); + // Compute axis domains with padding (from ALL data, not just visible) + const allX = allRegions.flatMap((r) => r.points.map((p) => p[0])); + const allY = allRegions.flatMap((r) => r.points.map((p) => p[1])); const xMin = Math.min(...allX); const xMax = Math.max(...allX); const yMin = Math.min(...allY); @@ -451,25 +478,34 @@ export default function ScatterPlot({ </span> </div> - {/* Legend */} + {/* Model toggles */} <div style={{ display: "flex", - gap: "16px", + gap: "8px", justifyContent: "center", - marginBottom: "8px", - fontSize: "12px", - fontFamily: "'JetBrains Mono', monospace", - color: "hsl(213 14% 65%)", + marginBottom: "12px", + flexWrap: "wrap", }} > - {regions.map((r) => ( - <div key={r.model} style={{ display: "flex", alignItems: "center", gap: "6px" }}> - <svg width={12} height={12}> - <circle cx={6} cy={6} r={5} fill={fallbackColor(r.model)} /> - </svg> - <span>{r.model}</span> - </div> + {allModels.map((model) => ( + <button + key={model} + onClick={() => toggleModel(model)} + style={{ + padding: "4px 10px", + borderRadius: "0", + border: `1px solid ${fallbackColor(model)}`, + background: effectiveVisible.has(model) ? `${fallbackColor(model)}22` : "transparent", + color: effectiveVisible.has(model) ? fallbackColor(model) : "var(--text-muted, hsl(213 14% 55%))", + opacity: effectiveVisible.has(model) ? 1 : 0.4, + cursor: "pointer", + fontSize: "0.75rem", + fontFamily: "var(--font-mono, 'JetBrains Mono', monospace)", + }} + > + {model} + </button> ))} </div> @@ -502,7 +538,7 @@ export default function ScatterPlot({ {/* Hidden scatter to seed axis scales with data */} <ZAxis range={[0, 0]} /> <Scatter - data={regions.flatMap((r) => r.points.map((p) => ({ x: p[0], y: p[1] })))} + data={allRegions.flatMap((r) => r.points.map((p) => ({ x: p[0], y: p[1] })))} fill="transparent" isAnimationActive={false} />

Impressum · Datenschutz