SkateSpots/src/components/DeckDesigner.tsx
SkateSpots b247be13b0 DIY: parametric 2D deck designer with 1:1 printable template
New /diy/deck-designer tool. src/lib/deckDesign.ts holds the pure geometry
(outline, waist, rounded ends, kicks, concave) and the exporters; the component
renders a live top view, side profile and concave section with presets, saved
locally.

Exports: true-size SVG, a tiled 1:1 PDF (A4/Letter, split across sheets with
corner registration crosses), and an R12 DXF (outline polyline + 3/16" truck
holes + centreline, $INSUNITS=4). Truck patterns: New school 53.975x41.275 mm
and Old school 63.5x41.275 mm.

Deliberately 2D: this is the outline a builder traces and cuts, not a SK8CAD
clone (SK8CAD is proprietary; only its JSCAD engine is MIT, and we copied
nothing).
2026-10-05 11:16:34 +07:00

282 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Download, FileText } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import {
DECK_HOLE_RADIUS,
DECK_PRESETS,
DEFAULT_DECK,
deckDxf,
deckHoles,
deckLength,
deckOutline,
deckPath,
deckSidePoints,
deckSvg,
type DeckParams,
} from '@/lib/deckDesign';
/**
* Parametric 2D deck designer — outline + printable 1:1 template + DXF.
*
* Deliberately 2D (a template you trace and cut), not a 3D CAD clone. State is
* local-only (localStorage). See `src/lib/deckDesign.ts` for the geometry.
*/
export function DeckDesigner() {
const { t } = useTranslation();
const { toast } = useToast();
const [raw, setRaw] = useLocalStorage<DeckParams>('skatespots:deck-design:v1', DEFAULT_DECK);
const [busy, setBusy] = useState(false);
const p = useMemo<DeckParams>(() => ({ ...DEFAULT_DECK, ...raw }), [raw]);
const patch = (next: Partial<DeckParams>) => setRaw({ ...DEFAULT_DECK, ...raw, ...next });
const totalLen = deckLength(p);
const holes = useMemo(() => deckHoles(p), [p]);
const inches = (mm: number) => (mm / 25.4).toFixed(2);
const label = 'text-[11px] font-semibold text-gray-400';
const download = (name: string, text: string, mime: string) => {
const url = URL.createObjectURL(new Blob([text], { type: mime }));
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
};
const exportSvg = () => download(`deck-${Math.round(totalLen)}x${Math.round(p.width)}mm.svg`, deckSvg(p), 'image/svg+xml');
const exportDxf = () => download(`deck-${Math.round(totalLen)}x${Math.round(p.width)}mm.dxf`, deckDxf(p), 'application/dxf');
/** Tiled 1:1 PDF: split the outline across A4/Letter sheets with registration marks. */
const exportPdf = async (paper: 'a4' | 'letter') => {
setBusy(true);
try {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF({ unit: 'mm', format: paper, orientation: 'landscape' });
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const M = 10;
const tileW = pageW - 2 * M;
const tileH = pageH - 2 * M;
const W = p.width;
const pts = deckOutline(p);
const cols = Math.max(1, Math.ceil(totalLen / tileW));
const rows = Math.max(1, Math.ceil(W / tileH));
const tx = (x: number, c: number) => M + x - c * tileW;
const ty = (y: number, r: number) => M + (y + W / 2) - r * tileH;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (r > 0 || c > 0) doc.addPage();
// Outline, drawn as deltas (doc.lines) so no path-API dependency.
const deltas: [number, number][] = [];
for (let i = 1; i < pts.length; i++) deltas.push([pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y]);
deltas.push([pts[0].x - pts[pts.length - 1].x, pts[0].y - pts[pts.length - 1].y]);
doc.setDrawColor(0); doc.setLineWidth(0.25);
doc.lines(deltas, tx(pts[0].x, c), ty(pts[0].y, r), [1, 1], 'S', true);
for (const h of holes) doc.circle(tx(h.x, c), ty(h.y, r), DECK_HOLE_RADIUS, 'S');
// Registration crosses at the tile corners (adjacent pages align on them).
doc.setDrawColor(150); doc.setLineWidth(0.15);
for (const [px, py] of [[M, M], [M + tileW, M], [M, M + tileH], [M + tileW, M + tileH]] as [number, number][]) {
doc.line(px - 3, py, px + 3, py);
doc.line(px, py - 3, px, py + 3);
}
doc.setFontSize(7); doc.setTextColor(110);
doc.text(`Deck template · ${Math.round(totalLen)} × ${Math.round(W)} mm · 1:1 · print at 100% · tile ${r + 1},${c + 1} of ${rows}×${cols}`, M, pageH - 4);
}
}
doc.save(`deck-template-1to1-${paper}.pdf`);
} catch {
toast({ title: 'Could not build the PDF', variant: 'destructive' });
} finally {
setBusy(false);
}
};
const field = (key: keyof DeckParams, labelText: string, opts?: { min?: number; max?: number; step?: number; hint?: string }) => (
<label className="space-y-1">
<span className={label}>{labelText}</span>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={opts?.min}
max={opts?.max}
step={opts?.step ?? 1}
value={p[key] as number}
onChange={(e) => patch({ [key]: Number(e.target.value) } as Partial<DeckParams>)}
className="h-8 bg-black/40 border-white/10"
/>
{opts?.hint && <span className="shrink-0 text-[10px] text-gray-500">{opts.hint}</span>}
</div>
</label>
);
return (
<div className="space-y-5">
{/* Presets */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.presets')}</span>
{DECK_PRESETS.map((preset) => (
<button
key={preset.name}
type="button"
onClick={() => setRaw({ ...DEFAULT_DECK, ...preset })}
className="min-h-0 rounded-full border border-white/15 px-3 py-1 text-xs font-semibold text-gray-300 hover:border-[#ccff00]/60 hover:text-white"
>
{preset.name}
</button>
))}
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)]">
{/* Inputs */}
<div className="space-y-4">
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.dimensions')}</p>
<div className="grid grid-cols-2 gap-3">
{field('width', t('diyUi.designer.width'), { min: 20, hint: `≈ ${inches(p.width)}″` })}
{field('wheelbase', t('diyUi.designer.wheelbase'), { min: 30, hint: `≈ ${inches(p.wheelbase)}″` })}
{field('nose', t('diyUi.designer.nose'), { min: 5, hint: `≈ ${inches(p.nose)}″` })}
{field('tail', t('diyUi.designer.tail'), { min: 5, hint: `≈ ${inches(p.tail)}″` })}
</div>
<p className="text-[11px] text-gray-500">
{t('diyUi.designer.length')} <span className="font-bold text-gray-300">{totalLen.toFixed(0)} mm</span> ({inches(totalLen)}″)
</p>
</section>
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.shape')}</p>
<div className="grid grid-cols-2 gap-3">
{field('waist', t('diyUi.designer.waist'), { min: 0, max: 0.15, step: 0.01 })}
{field('endRound', t('diyUi.designer.endRound'), { min: 1.2, max: 4, step: 0.1 })}
{field('concave', t('diyUi.designer.concave'), { min: 0, hint: 'mm' })}
{field('thickness', t('diyUi.designer.thickness'), { min: 2, hint: 'mm' })}
</div>
</section>
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.kicks')}</p>
<div className="grid grid-cols-2 gap-3">
{field('tailKick', t('diyUi.designer.tailKick'), { min: 0, max: 40, hint: '°' })}
{field('noseKick', t('diyUi.designer.noseKick'), { min: 0, max: 40, hint: '°' })}
</div>
</section>
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.hardware')}</p>
<div className="flex gap-2">
{(['new', 'old'] as const).map((pattern) => (
<button
key={pattern}
type="button"
onClick={() => patch({ holePattern: pattern })}
className={`min-h-0 flex-1 rounded-md border px-2 py-1.5 text-xs font-semibold ${p.holePattern === pattern ? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white' : 'border-white/15 text-gray-300 hover:bg-white/5'}`}
>
{t(`diyUi.designer.hole${pattern === 'new' ? 'New' : 'Old'}`)}
</button>
))}
</div>
<p className="text-[10px] text-gray-500">{t('diyUi.designer.holeSpacing')}</p>
</section>
</div>
{/* Preview + export */}
<div className="space-y-4">
<section className="rounded-xl border border-white/10 bg-black/30 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.topView')}</p>
<span className="text-[10px] text-gray-500">{Math.round(totalLen)} × {Math.round(p.width)} mm</span>
</div>
<svg viewBox={`${-8} ${-p.width / 2 - 8} ${totalLen + 16} ${p.width + 16}`} className="h-auto w-full" preserveAspectRatio="xMidYMid meet">
<line x1={0} y1={0} x2={totalLen} y2={0} stroke="#7f1d1d" strokeWidth={1} strokeDasharray="6 4" vectorEffect="non-scaling-stroke" />
<path d={deckPath(p)} fill="rgba(204,255,0,0.07)" stroke="#ccff00" strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
{holes.map((h, i) => (
<circle key={i} cx={h.x} cy={h.y} r={DECK_HOLE_RADIUS} fill="none" stroke="#f43f5e" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
))}
</svg>
</section>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<section className="rounded-xl border border-white/10 bg-black/30 p-3">
<p className="mb-2 text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.sideView')}</p>
<SideView p={p} />
</section>
<section className="rounded-xl border border-white/10 bg-black/30 p-3">
<p className="mb-2 text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.concaveSection')}</p>
<CrossSection p={p} />
</section>
</div>
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.export')}</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={exportSvg} className="gap-1.5 bg-[#ccff00] text-black hover:bg-[#b8e600]">
<Download className="h-3.5 w-3.5" /> SVG
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => void exportPdf('a4')} className="gap-1.5 border-white/20">
<FileText className="h-3.5 w-3.5" /> {t('diyUi.designer.pdfA4')}
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => void exportPdf('letter')} className="gap-1.5 border-white/20">
<FileText className="h-3.5 w-3.5" /> {t('diyUi.designer.pdfLetter')}
</Button>
<Button size="sm" variant="outline" onClick={exportDxf} className="gap-1.5 border-white/20">
<Download className="h-3.5 w-3.5" /> DXF
</Button>
</div>
<p className="text-[11px] text-gray-500">{t('diyUi.designer.printNote')}</p>
<p className="text-[11px] text-gray-500">{t('diyUi.designer.note')}</p>
</section>
</div>
</div>
</div>
);
}
/** Small profile drawing: flat middle, kicked nose and tail. */
function SideView({ p }: { p: DeckParams }) {
const sp = deckSidePoints(p);
const totalLen = deckLength(p);
const maxRise = Math.max(...sp.map((s) => s.y), 1);
const H = maxRise + p.thickness + 4;
const flip = (y: number) => H - y;
const top = sp.map((s) => `${s.x} ${flip(s.y)}`).join(' L ');
const bottom = [...sp].reverse().map((s) => `${s.x} ${flip(s.y - p.thickness)}`).join(' L ');
return (
<svg viewBox={`${-4} ${-4} ${totalLen + 8} ${H + 8}`} className="h-auto w-full" preserveAspectRatio="xMidYMid meet">
<line x1={0} y1={flip(0)} x2={totalLen} y2={flip(0)} stroke="#374151" strokeWidth={1} vectorEffect="non-scaling-stroke" />
<path d={`M ${top} L ${bottom} Z`} fill="rgba(204,255,0,0.10)" stroke="#ccff00" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
</svg>
);
}
/** Small cross-section drawing: the concave arc across the width. */
function CrossSection({ p }: { p: DeckParams }) {
const W = p.width;
const dip = p.concave;
const samples = 40;
const pts: string[] = [];
for (let i = 0; i <= samples; i++) {
const x = (W * i) / samples;
const t = (2 * x) / W - 1; // -1..1
const y = -dip * (1 - t * t); // parabola, deepest at the centre
pts.push(`${x} ${y}`);
}
const H = Math.max(dip, 2) + 4;
const d = `M 0 0 L ${pts.join(' L ')} L ${W} 0`;
return (
<svg viewBox={`${-4} ${-H} ${W + 8} ${H + 8}`} className="h-auto w-full" preserveAspectRatio="xMidYMid meet">
<path d={d} fill="rgba(204,255,0,0.10)" stroke="#ccff00" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
<line x1={0} y1={0} x2={W} y2={0} stroke="#374151" strokeWidth={1} strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />
<text x={W / 2} y={-H + 3} fontSize={Math.max(4, W / 30)} textAnchor="middle" fill="#9ca3af" fontFamily="sans-serif">
{dip} mm concave · {Math.round(W)} mm
</text>
</svg>
);
}