feat: style the UI with Oat and make the empty canvas a click-to-load drop zone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
353577d5ec
commit
98285d0539
5 files changed
+86
-38
No files matched your search
Generated
+6
@@ -8,6 +8,7 @@
|
||||
"name": "scrap-tracer",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@knadh/oat": "0.8.3",
|
||||
"@techstark/opencv-js": "4.12.0-release.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -67,6 +68,11 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@knadh/oat": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@knadh/oat/-/oat-0.8.3.tgz",
|
||||
"integrity": "sha512-eE07LJ6RpC21PfEbu3U6dFbTO0V6m1OcXocrYr7dwN+Ffr8sx+SqVTPwHO5/+Azv0NWEU8V7frwzBQXdx7CzLA=="
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@knadh/oat": "0.8.3",
|
||||
"@techstark/opencv-js": "4.12.0-release.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+31
-7
@@ -19,6 +19,11 @@
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) app.loadFile(file);
|
||||
}
|
||||
|
||||
function onFile(e: Event & { currentTarget: HTMLInputElement }) {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (file) app.loadFile(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="layout">
|
||||
@@ -29,7 +34,11 @@
|
||||
{#if app.image}
|
||||
<ImageCanvas {app} />
|
||||
{:else}
|
||||
<p class="empty">Drop a photo of a scrap piece here, or use “Load photo”.</p>
|
||||
<label class="dropzone">
|
||||
<input type="file" accept="image/*" hidden onchange={onFile} />
|
||||
<strong>Drop a photo of a scrap piece here</strong>
|
||||
<span>or click to choose a file</span>
|
||||
</label>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
@@ -37,8 +46,6 @@
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #222;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
@@ -47,16 +54,33 @@
|
||||
}
|
||||
aside {
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #ddd;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
}
|
||||
main {
|
||||
position: relative;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
.empty {
|
||||
.dropzone {
|
||||
position: absolute;
|
||||
inset: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: 2px dashed #555;
|
||||
border-radius: 12px;
|
||||
color: #bbb;
|
||||
text-align: center;
|
||||
margin-top: 40vh;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
}
|
||||
.dropzone:hover {
|
||||
border-color: #888;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.dropzone span {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@
|
||||
import { scrapFilename, shapeSize } from '../lib/geometry';
|
||||
|
||||
let { app }: { app: AppState } = $props();
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
const size = $derived(app.exportShape ? shapeSize(app.exportShape) : null);
|
||||
const nothingDetected = $derived(app.cv !== null && app.image !== null && app.shapePx === null);
|
||||
@@ -30,24 +31,27 @@
|
||||
<h1>Scrap Tracer</h1>
|
||||
|
||||
{#if app.cvError}
|
||||
<p class="error">{app.cvError} <button onclick={() => location.reload()}>Reload</button></p>
|
||||
<p class="error">{app.cvError} <button class="outline small" onclick={() => location.reload()}>Reload</button></p>
|
||||
{:else if !app.cv}
|
||||
<p class="muted">Loading image processing…</p>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h2>1. Photo</h2>
|
||||
<label class="file">Load photo <input type="file" accept="image/*" onchange={onFile} /></label>
|
||||
<button class="outline" onclick={() => fileInput.click()}>{app.image ? 'Replace photo' : 'Load photo'}</button>
|
||||
<input bind:this={fileInput} type="file" accept="image/*" hidden onchange={onFile} />
|
||||
{#if app.loadError}<p class="error">{app.loadError}</p>{/if}
|
||||
</section>
|
||||
|
||||
<section class:disabled={!app.image}>
|
||||
<h2>2. Scale</h2>
|
||||
<p class="muted">Click both ends of something whose length you know.</p>
|
||||
<button class:active={app.tool === 'scale'} onclick={() => toggle('scale')} disabled={!app.image}>
|
||||
{app.scalePoints.length === 2 ? 'Re-measure' : 'Measure'}
|
||||
</button>
|
||||
<label>Length <input type="number" min="1" step="any" bind:value={app.lengthMm} /> mm</label>
|
||||
<div class="row">
|
||||
<button class:outline={app.tool !== 'scale'} onclick={() => toggle('scale')} disabled={!app.image}>
|
||||
{app.scalePoints.length === 2 ? 'Re-measure' : 'Measure'}
|
||||
</button>
|
||||
<label class="inline">Length <input type="number" min="1" step="any" bind:value={app.lengthMm} /> mm</label>
|
||||
</div>
|
||||
{#if app.scaleInvalid}
|
||||
<p class="error">Enter a length above 0 and pick two different points.</p>
|
||||
{:else if app.pxPerMm}
|
||||
@@ -58,19 +62,26 @@
|
||||
<section class:disabled={!app.image}>
|
||||
<h2>3. Outline</h2>
|
||||
{#if app.mode === 'auto'}
|
||||
<label>Threshold <input type="range" min="0" max="255" bind:value={app.threshold} /> {app.threshold}</label>
|
||||
<button onclick={() => app.autoThreshold()} disabled={!app.cv || !app.image}>Auto</button>
|
||||
<button class:active={app.tool === 'seed'} onclick={() => toggle('seed')} disabled={!app.image}>Click to pick piece</button>
|
||||
{/if}
|
||||
<button class:active={app.tool === 'draw'} onclick={() => toggle('draw')} disabled={!app.image}>
|
||||
{app.manualOuter.length === 0 ? 'Draw outline manually' : 'Draw a hole'}
|
||||
</button>
|
||||
{#if app.drawing.length > 0}
|
||||
<button onclick={() => app.finishPolygon()}>Finish {app.manualOuter.length === 0 ? 'outline' : 'hole'}</button>
|
||||
{/if}
|
||||
{#if app.mode === 'manual' || app.drawing.length > 0}
|
||||
<button onclick={() => app.clearManual()}>Back to automatic</button>
|
||||
<label>
|
||||
<span class="label-row">Threshold <span class="value">{app.threshold}</span></span>
|
||||
<input type="range" min="0" max="255" bind:value={app.threshold} />
|
||||
</label>
|
||||
<div class="row">
|
||||
<button class="outline" onclick={() => app.autoThreshold()} disabled={!app.cv || !app.image}>Auto</button>
|
||||
<button class:outline={app.tool !== 'seed'} onclick={() => toggle('seed')} disabled={!app.image}>Click to pick piece</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row">
|
||||
<button class:outline={app.tool !== 'draw'} onclick={() => toggle('draw')} disabled={!app.image}>
|
||||
{app.manualOuter.length === 0 ? 'Draw outline manually' : 'Draw a hole'}
|
||||
</button>
|
||||
{#if app.drawing.length > 0}
|
||||
<button onclick={() => app.finishPolygon()}>Finish {app.manualOuter.length === 0 ? 'outline' : 'hole'}</button>
|
||||
{/if}
|
||||
{#if app.mode === 'manual' || app.drawing.length > 0}
|
||||
<button class="ghost" onclick={() => app.clearManual()}>Back to automatic</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if nothingDetected}
|
||||
<p class="warning">Nothing detected, try adjusting the threshold, clicking the piece, or draw manually.</p>
|
||||
{/if}
|
||||
@@ -79,8 +90,8 @@
|
||||
<section class:disabled={!app.pxPerMm}>
|
||||
<h2>4. Safety margin</h2>
|
||||
<label>
|
||||
<span class="label-row">Margin <span class="value">{app.marginMm} mm</span></span>
|
||||
<input type="range" min="-10" max="5" step="0.5" bind:value={app.marginMm} disabled={!app.pxPerMm} />
|
||||
{app.marginMm} mm
|
||||
</label>
|
||||
{#if !app.pxPerMm}<p class="muted">Set the scale first.</p>{/if}
|
||||
</section>
|
||||
@@ -88,8 +99,10 @@
|
||||
<section>
|
||||
<h2>5. Export</h2>
|
||||
{#if size}<p>Piece: {Math.round(size.width)} × {Math.round(size.height)} mm</p>{/if}
|
||||
<button class="primary" onclick={() => exportAs('dxf')} disabled={!app.exportShape}>Download DXF</button>
|
||||
<button onclick={() => exportAs('svg')} disabled={!app.exportShape}>Download SVG</button>
|
||||
<div class="row">
|
||||
<button onclick={() => exportAs('dxf')} disabled={!app.exportShape}>Download DXF</button>
|
||||
<button class="outline" onclick={() => exportAs('svg')} disabled={!app.exportShape}>Download SVG</button>
|
||||
</div>
|
||||
{#if !app.exportShape}
|
||||
<p class="muted">{app.pxPerMm ? 'No outline to export yet.' : 'Set the scale first.'}</p>
|
||||
{/if}
|
||||
@@ -97,15 +110,18 @@
|
||||
|
||||
<style>
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
h2 { font-size: 1rem; margin: 0 0 6px; }
|
||||
section { padding: 12px 0; border-top: 1px solid #eee; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
|
||||
h2 { font-size: 1rem; margin: 0; }
|
||||
p { margin: 0; }
|
||||
section { padding: 14px 0; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
|
||||
section.disabled { opacity: 0.5; }
|
||||
label { display: flex; gap: 6px; align-items: center; }
|
||||
input[type='number'] { width: 80px; }
|
||||
.file input { max-width: 200px; }
|
||||
button.active { outline: 2px solid #2962ff; }
|
||||
button.primary { font-weight: bold; }
|
||||
.muted { color: #777; margin: 0; font-size: 0.9rem; }
|
||||
.error { color: #c62828; margin: 0; }
|
||||
.warning { color: #e65100; margin: 0; }
|
||||
section > label { align-self: stretch; }
|
||||
input[type='range'] { width: 100%; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.inline { display: flex; gap: 6px; align-items: center; }
|
||||
.inline input { width: 90px; margin: 0; }
|
||||
.label-row { display: flex; justify-content: space-between; }
|
||||
.value { color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--muted-foreground); font-size: 0.875rem; }
|
||||
.error { color: var(--danger); }
|
||||
.warning { color: var(--warning); }
|
||||
</style>
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@knadh/oat/oat.min.css';
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
|
||||
|
||||
Reference in new issue
Block a user