Initial commit
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": false,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Svelte + TS + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Svelte and TypeScript in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|
||||||
|
## Need an official Svelte framework?
|
||||||
|
|
||||||
|
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||||
|
|
||||||
|
## Technical considerations
|
||||||
|
|
||||||
|
**Why use this over SvelteKit?**
|
||||||
|
|
||||||
|
- It brings its own routing solution which might not be preferable for some users.
|
||||||
|
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||||
|
|
||||||
|
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||||
|
|
||||||
|
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||||
|
|
||||||
|
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
|
||||||
|
|
||||||
|
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
|
||||||
|
|
||||||
|
**Why include `.vscode/extensions.json`?**
|
||||||
|
|
||||||
|
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||||
|
|
||||||
|
**Why enable `allowJs` in the TS template?**
|
||||||
|
|
||||||
|
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
|
||||||
|
|
||||||
|
**Why is HMR not preserving my local component state?**
|
||||||
|
|
||||||
|
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
|
||||||
|
|
||||||
|
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// store.ts
|
||||||
|
// An extremely simple external store
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
export default writable(0)
|
||||||
|
```
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Write to Inkclip</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "inkclip-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
|
"@types/w3c-web-hid": "^1.0.6",
|
||||||
|
"rgbquant": "^1.1.2",
|
||||||
|
"svelte": "^5.25.2",
|
||||||
|
"svelte-check": "^4.1.4",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg version="1.1" width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<polygon points="0,0 255,0 0,255" fill="#afbabc" />
|
||||||
|
<polygon points="255,255 255,0 0,255" fill="#182435" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 199 B |
+128
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Canvas from './lib/Canvas.svelte'
|
||||||
|
import ConnectButton from './lib/ConnectButton.svelte'
|
||||||
|
import WriteButton from './lib/WriteButton.svelte'
|
||||||
|
|
||||||
|
const hid = navigator.hid
|
||||||
|
|
||||||
|
let inProgress = $state(false)
|
||||||
|
let device: HIDDevice | null = $state(null)
|
||||||
|
let bitmapData: number[] | null = $state(null)
|
||||||
|
|
||||||
|
function isInkclip(dev: HIDDevice) {
|
||||||
|
return dev.vendorId == 0xc0de && dev.productId == 0xcafe
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryGetPairedDevice() {
|
||||||
|
const dev = (await hid.getDevices()).find(isInkclip)
|
||||||
|
if (dev !== undefined) device = dev
|
||||||
|
}
|
||||||
|
|
||||||
|
hid.addEventListener('connect', e => {
|
||||||
|
if (isInkclip(e.device) && device === null) device = e.device
|
||||||
|
})
|
||||||
|
|
||||||
|
hid.addEventListener('disconnect', e => {
|
||||||
|
if (device === e.device) device = null
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tryGetPairedDevice()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="section--connect">
|
||||||
|
<div class="section-text">
|
||||||
|
<h1 class="section-title">Connect to a device</h1>
|
||||||
|
|
||||||
|
{#if device !== null}
|
||||||
|
Successfully conected to device. If you want to, you can connect to another device instead.
|
||||||
|
{:else}
|
||||||
|
Not connected to any device yet. Plug in your device, and click the button to select it.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConnectButton
|
||||||
|
onconnect={dev => {
|
||||||
|
device = dev
|
||||||
|
}}
|
||||||
|
{device}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section--edit">
|
||||||
|
<h1 class="section-title">Choose an image</h1>
|
||||||
|
|
||||||
|
<Canvas
|
||||||
|
onchange={v => {
|
||||||
|
bitmapData = v
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section--write">
|
||||||
|
<div class="section-text">
|
||||||
|
<h1 class="section-title">Write pattern to device</h1>
|
||||||
|
|
||||||
|
{#if device === null}
|
||||||
|
Connect your device to start writing patterns onto it.
|
||||||
|
{:else if bitmapData === null}
|
||||||
|
Select an image file in order to write it onto your device.
|
||||||
|
{:else if !inProgress}
|
||||||
|
Write the pattern onto your device if you have finished editing the image.
|
||||||
|
{:else}
|
||||||
|
Writing in progress. Do not disconnect device.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WriteButton
|
||||||
|
{device}
|
||||||
|
data={bitmapData}
|
||||||
|
onprogress={v => {
|
||||||
|
inProgress = v
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
padding: 1em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1em;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background-color: #fff1;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 5px;
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-text {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section--connect,
|
||||||
|
.section--write {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section--edit {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
section {
|
||||||
|
background-color: #0001;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Fira Sans, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5em;
|
||||||
|
color-scheme: dark light;
|
||||||
|
background-color: #222;
|
||||||
|
color: #eee;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: Fira Sans, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
background-color: #fff1;
|
||||||
|
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
transition: 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #fff2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
background-color: #fff3;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: #0003;
|
||||||
|
color: #888;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
body {
|
||||||
|
background-color: #ddd;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #0001;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #0002;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
background-color: #0003;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: #fff3;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Transform, withCtx, withTransform } from './image/transform'
|
||||||
|
import { Quantizer, type DitheringKernel } from './image/quantizer'
|
||||||
|
import { Scaler, type ScaleMode } from './image/scaler'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onchange: (bitmap: number[] | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaler = new Scaler(200, 200)
|
||||||
|
|
||||||
|
const { onchange }: Props = $props()
|
||||||
|
|
||||||
|
let scaledCanvasEl: HTMLCanvasElement
|
||||||
|
let unscaledCanvasEl: HTMLCanvasElement
|
||||||
|
|
||||||
|
let fileList: FileList | null = $state(null)
|
||||||
|
let imageBitmap: ImageBitmap | null = $state(null)
|
||||||
|
|
||||||
|
let backgroundColor: number = $state(255)
|
||||||
|
|
||||||
|
let imageState: Transform = $state(new Transform())
|
||||||
|
let scalingMethod: ScaleMode = $state('fit')
|
||||||
|
|
||||||
|
let dither = $state(true)
|
||||||
|
let ditheringKernel: DitheringKernel = $state('FloydSteinberg')
|
||||||
|
|
||||||
|
let saturation = $state(0)
|
||||||
|
let bias = $state(0)
|
||||||
|
|
||||||
|
const quantizer = $derived(
|
||||||
|
new Quantizer({
|
||||||
|
ditheringKernel: dither ? ditheringKernel : null,
|
||||||
|
saturation,
|
||||||
|
bias,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function imageNonSquare() {
|
||||||
|
if (imageBitmap === null) return false
|
||||||
|
return imageBitmap.height !== imageBitmap.width
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDefaultImageSettings() {
|
||||||
|
backgroundColor = 255
|
||||||
|
imageState = new Transform()
|
||||||
|
scalingMethod = 'fit'
|
||||||
|
dither = true
|
||||||
|
ditheringKernel = 'FloydSteinberg'
|
||||||
|
saturation = 0
|
||||||
|
bias = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshContext() {
|
||||||
|
const ctx = scaledCanvasEl.getContext('2d', {
|
||||||
|
willReadFrequently: true,
|
||||||
|
})!
|
||||||
|
ctx.clearRect(0, 0, 200, 200)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
|
||||||
|
withCtx(
|
||||||
|
ctx,
|
||||||
|
() => {
|
||||||
|
ctx.imageSmoothingEnabled = false
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
for (let y = 0; y < 200; y++) {
|
||||||
|
for (let x = 0; x < 200; x++) {
|
||||||
|
const color = data.at(y * 200 + x)
|
||||||
|
ctx.fillStyle = color === 0 ? '#fff' : '#000'
|
||||||
|
ctx.fillRect(x, y, 1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateImageBitmap() {
|
||||||
|
if (fileList === null || fileList.length < 1) {
|
||||||
|
imageBitmap = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFile = fileList[0]
|
||||||
|
try {
|
||||||
|
imageBitmap = await createImageBitmap(imageFile)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
imageBitmap = null
|
||||||
|
}
|
||||||
|
imageState = new Transform()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawToCanvas() {
|
||||||
|
const ctx = freshContext()
|
||||||
|
|
||||||
|
if (imageBitmap === null) {
|
||||||
|
onchange(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
withTransform(ctx, imageState, () => {
|
||||||
|
if (imageBitmap === null) return
|
||||||
|
|
||||||
|
ctx.fillStyle = `rgb(${backgroundColor} ${backgroundColor} ${backgroundColor})`
|
||||||
|
ctx.fillRect(0, 0, 200, 200)
|
||||||
|
const { dx, dy, dWidth, dHeight } = scaler.scale(imageBitmap, scalingMethod)
|
||||||
|
ctx.drawImage(imageBitmap, dx, dy, dWidth, dHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
const quantizedData = quantizer.reduce(ctx)
|
||||||
|
drawQuantizedData(ctx, quantizedData)
|
||||||
|
drawQuantizedData(unscaledCanvasEl.getContext('2d')!, quantizedData)
|
||||||
|
|
||||||
|
onchange(quantizedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
updateImageBitmap()
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
drawToCanvas()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="canvas-container">
|
||||||
|
<div>
|
||||||
|
<input type="file" id="image-file" name="image_file" accept="image/*" bind:files={fileList} />
|
||||||
|
<canvas class="scaled" bind:this={scaledCanvasEl} height={200} width={200}></canvas>
|
||||||
|
<canvas bind:this={unscaledCanvasEl} height={200} width={200}></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if imageNonSquare()}
|
||||||
|
<div>
|
||||||
|
<h2>Scaling</h2>
|
||||||
|
You need to choose how to scale your image since it is not square.
|
||||||
|
<br />
|
||||||
|
<select name="scaling_method" id="scaling-method-select" bind:value={scalingMethod}>
|
||||||
|
<option value="fit">Fit</option>
|
||||||
|
<option value="crop">Crop</option>
|
||||||
|
<option value="distort">Distort</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="transforms">
|
||||||
|
<h2>Transform</h2>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
imageState = imageState.cw()
|
||||||
|
}}
|
||||||
|
name="Rotate clockwise"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
imageState = imageState.ccw()
|
||||||
|
}}
|
||||||
|
name="Rotate counter-clockwise"
|
||||||
|
>
|
||||||
|
↺
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
imageState = imageState.h()
|
||||||
|
}}
|
||||||
|
name="Flip horizontally"
|
||||||
|
>
|
||||||
|
↔
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
imageState = imageState.v()
|
||||||
|
}}
|
||||||
|
name="Flip vertically"
|
||||||
|
>
|
||||||
|
↕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Rendering</h2>
|
||||||
|
Background Color:<input
|
||||||
|
type="range"
|
||||||
|
name="background_color"
|
||||||
|
id="background-color-input"
|
||||||
|
bind:value={backgroundColor}
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
{backgroundColor}
|
||||||
|
<br />
|
||||||
|
<input type="checkbox" name="dither" id="dither-checkbox" bind:checked={dither} /> Dither
|
||||||
|
{#if dither}
|
||||||
|
<br />
|
||||||
|
Dithering Kernel:
|
||||||
|
<select name="dithering_kernel" id="dithering-kernel-select" bind:value={ditheringKernel}>
|
||||||
|
<option value="FloydSteinberg">Floyd-Steinberg</option>
|
||||||
|
<option value="FalseFloydSteinberg">False Floyd-Steinberg</option>
|
||||||
|
<option value="Stucki">Stucki</option>
|
||||||
|
<option value="Atkinson">Atkinson</option>
|
||||||
|
<option value="Jarvis">Jarvis</option>
|
||||||
|
<option value="Burkes">Burkes</option>
|
||||||
|
<option value="Sierra">Sierra</option>
|
||||||
|
<option value="TwoSierra">2-Sierra</option>
|
||||||
|
<option value="SierraLite">Sierra Lite</option>
|
||||||
|
</select>
|
||||||
|
<br />
|
||||||
|
Saturation:
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
name="saturation"
|
||||||
|
id="saturation-input"
|
||||||
|
bind:value={saturation}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
{Math.floor(saturation * 100)}%
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !dither || saturation !== 0}
|
||||||
|
<br />
|
||||||
|
Bias:
|
||||||
|
<input type="range" name="bias" id="bias-input" bind:value={bias} min={-1} max={1} step={0.01} />
|
||||||
|
{Math.floor(bias * 100)}%
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<br />
|
||||||
|
(white = {Math.floor(quantizer.white)}, black = {Math.floor(quantizer.black)})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Reset</h2>
|
||||||
|
<button onclick={restoreDefaultImageSettings}>Reset All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaled {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transforms button {
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const hid = navigator.hid
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onconnect: (device: HIDDevice) => void
|
||||||
|
device: HIDDevice | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onconnect, device }: Props = $props()
|
||||||
|
|
||||||
|
async function requestDevice() {
|
||||||
|
const devs = await hid.requestDevice({
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
vendorId: 0xc0de,
|
||||||
|
productId: 0xcafe,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (devs == undefined || devs[0] == undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onconnect(devs[0])
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button onclick={requestDevice}>
|
||||||
|
{#if device == null}
|
||||||
|
Choose device
|
||||||
|
{:else}
|
||||||
|
Connect to another device
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
device: HIDDevice | null
|
||||||
|
data: number[] | null
|
||||||
|
onprogress: (inPropgress: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const { device, data, onprogress }: Props = $props()
|
||||||
|
let inProgress = $state(false)
|
||||||
|
let disabled = $derived(device === null || data === null || inProgress)
|
||||||
|
|
||||||
|
async function connectAndWrite() {
|
||||||
|
if (device === null || data === null) return
|
||||||
|
|
||||||
|
if (!device.opened) {
|
||||||
|
try {
|
||||||
|
await device.open()
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Error)) throw e
|
||||||
|
alert('Unable to open device: ' + e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = new Uint8Array(5000)
|
||||||
|
for (let y = 0; y < 200; y++) {
|
||||||
|
for (let xStride = 0; xStride < 25; xStride++) {
|
||||||
|
let cell = 0x0
|
||||||
|
for (let xStroll = 0; xStroll < 8; xStroll++) {
|
||||||
|
const index = y * 200 + xStride * 8 + xStroll
|
||||||
|
cell |= data[index] << xStroll
|
||||||
|
}
|
||||||
|
buffer[y * 25 + xStride] = cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inProgress = true
|
||||||
|
setTimeout(() => {
|
||||||
|
inProgress = false
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
await device.sendReport(0, buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
onprogress(inProgress)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button onclick={connectAndWrite} {disabled}>Write pattern to device</button>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import RgbQuant from 'rgbquant'
|
||||||
|
import type { DitheringKernel, RgbQuantImage } from 'rgbquant'
|
||||||
|
|
||||||
|
export type { DitheringKernel, RgbQuantImage } from 'rgbquant'
|
||||||
|
|
||||||
|
export type QuantizerOptions = {
|
||||||
|
ditheringKernel: DitheringKernel | null
|
||||||
|
saturation: number
|
||||||
|
bias: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Quantizer {
|
||||||
|
public readonly black: number
|
||||||
|
public readonly white: number
|
||||||
|
|
||||||
|
private readonly rgbquant: RgbQuant
|
||||||
|
|
||||||
|
constructor({ ditheringKernel, saturation, bias }: QuantizerOptions) {
|
||||||
|
const saturationAdjustment = ditheringKernel === null ? 127 : 127 * saturation
|
||||||
|
this.white = 255 + (bias - 1) * saturationAdjustment
|
||||||
|
this.black = (1 + bias) * saturationAdjustment
|
||||||
|
|
||||||
|
this.rgbquant = new RgbQuant({
|
||||||
|
colors: 2,
|
||||||
|
dithKern: ditheringKernel,
|
||||||
|
palette: [
|
||||||
|
[this.white, this.white, this.white],
|
||||||
|
[this.black, this.black, this.black],
|
||||||
|
],
|
||||||
|
useCache: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
reduce(image: RgbQuantImage): number[] {
|
||||||
|
return this.rgbquant.reduce(image, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+51
@@ -0,0 +1,51 @@
|
|||||||
|
declare module 'rgbquant' {
|
||||||
|
type DitheringKernel =
|
||||||
|
| 'FloydSteinberg'
|
||||||
|
| 'FalseFloydSteinberg'
|
||||||
|
| 'Stucki'
|
||||||
|
| 'Atkinson'
|
||||||
|
| 'Jarvis'
|
||||||
|
| 'Burkes'
|
||||||
|
| 'Sierra'
|
||||||
|
| 'TwoSierra'
|
||||||
|
| 'SierraLite'
|
||||||
|
|
||||||
|
type Rgb = [number, number, number]
|
||||||
|
|
||||||
|
type RgbQuantOptions = {
|
||||||
|
colors?: number
|
||||||
|
method?: 1 | 2
|
||||||
|
boxSize?: [number, number]
|
||||||
|
boxPxls?: number
|
||||||
|
initColors?: number
|
||||||
|
minHueCols?: number
|
||||||
|
dithKern?: DitheringKernel | null
|
||||||
|
dithDelta?: number
|
||||||
|
dithSerp?: boolean
|
||||||
|
palette?: Rgb[]
|
||||||
|
reIndex?: boolean
|
||||||
|
useCache?: boolean
|
||||||
|
cacheFreq?: number
|
||||||
|
colorDist?: 'euclidean' | 'manhattan'
|
||||||
|
}
|
||||||
|
|
||||||
|
type RgbQuantImage =
|
||||||
|
| HTMLImageElement
|
||||||
|
| HTMLCanvasElement
|
||||||
|
| CanvasRenderingContext2D
|
||||||
|
| ImageData
|
||||||
|
| Uint8Array
|
||||||
|
| Uint8ClampedArray
|
||||||
|
| Uint32Array
|
||||||
|
| number[]
|
||||||
|
|
||||||
|
export default class RgbQuant {
|
||||||
|
constructor(opts?: RgbQuantOptions)
|
||||||
|
sample(image: RgbQuantImage, width?: number): void
|
||||||
|
palette(tuples: true, noSort?: boolean = false): Rgb[]
|
||||||
|
palette(tuples: false, noSort?: boolean = false): Uint8Array
|
||||||
|
palette(): Uint8Array
|
||||||
|
reduce(image: RgbQuantImage, retType: 1, dithKern?: DitheringKernel | null, dithSerp?: boolean): Uint8Array
|
||||||
|
reduce(image: RgbQuantImage, retType: 2, dithKern?: DitheringKernel | null, dithSerp?: boolean): number[]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export type DrawParameters = {
|
||||||
|
dx: number
|
||||||
|
dy: number
|
||||||
|
dWidth: number
|
||||||
|
dHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScaleMode = 'fit' | 'crop' | 'distort'
|
||||||
|
|
||||||
|
export class Scaler {
|
||||||
|
private readonly canvasAspectRatio: number
|
||||||
|
|
||||||
|
constructor(private readonly canvasWidth: number, private readonly canvasHeight: number) {
|
||||||
|
this.canvasAspectRatio = canvasWidth / canvasHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
fitScale(image: ImageBitmap): DrawParameters {
|
||||||
|
const { width, height } = image
|
||||||
|
const aspectRatio = width / height
|
||||||
|
|
||||||
|
let dx = 0
|
||||||
|
let dy = 0
|
||||||
|
let dWidth = this.canvasWidth
|
||||||
|
let dHeight = this.canvasHeight
|
||||||
|
|
||||||
|
if (aspectRatio > this.canvasAspectRatio) {
|
||||||
|
dHeight = dWidth / aspectRatio
|
||||||
|
dy = (this.canvasHeight - dHeight) / 2
|
||||||
|
} else if (aspectRatio < this.canvasAspectRatio) {
|
||||||
|
dWidth = dHeight * aspectRatio
|
||||||
|
dx = (this.canvasWidth - dWidth) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dx,
|
||||||
|
dy,
|
||||||
|
dWidth,
|
||||||
|
dHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cropScale(image: ImageBitmap): DrawParameters {
|
||||||
|
const { width, height } = image
|
||||||
|
const aspectRatio = width / height
|
||||||
|
|
||||||
|
let dx = 0
|
||||||
|
let dy = 0
|
||||||
|
let dWidth = this.canvasWidth
|
||||||
|
let dHeight = this.canvasHeight
|
||||||
|
|
||||||
|
if (aspectRatio > this.canvasAspectRatio) {
|
||||||
|
dWidth = dHeight * aspectRatio
|
||||||
|
dx = (this.canvasWidth - dWidth) / 2
|
||||||
|
} else if (aspectRatio < this.canvasAspectRatio) {
|
||||||
|
dHeight = dWidth / aspectRatio
|
||||||
|
dy = (this.canvasHeight - dHeight) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dx,
|
||||||
|
dy,
|
||||||
|
dWidth,
|
||||||
|
dHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
distortScale(): DrawParameters {
|
||||||
|
return {
|
||||||
|
dx: 0,
|
||||||
|
dy: 0,
|
||||||
|
dWidth: this.canvasWidth,
|
||||||
|
dHeight: this.canvasHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scale(image: ImageBitmap, mode: ScaleMode) {
|
||||||
|
switch (mode) {
|
||||||
|
case 'crop':
|
||||||
|
return this.cropScale(image)
|
||||||
|
case 'fit':
|
||||||
|
return this.fitScale(image)
|
||||||
|
case 'distort':
|
||||||
|
return this.distortScale()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export type Rotation = 0 | 90 | 180 | 270
|
||||||
|
export type Side = 'obverse' | 'reverse'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transform of a square image is represented by first rotating it by a multiple of 90 degrees, and then optionally
|
||||||
|
* flipping it horizontally.
|
||||||
|
*
|
||||||
|
* Algebraically, this is the dihedral group of order 8 (D₄).
|
||||||
|
*/
|
||||||
|
export class Transform {
|
||||||
|
constructor(public readonly side: Side = 'obverse', public readonly rotation: Rotation = 0) {}
|
||||||
|
|
||||||
|
cw(): Transform {
|
||||||
|
const rotationAngle = this.side === 'obverse' ? 90 : 270
|
||||||
|
const newAngle = (this.rotation + rotationAngle) % 360
|
||||||
|
return new Transform(this.side, newAngle as Rotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
ccw(): Transform {
|
||||||
|
const rotationAngle = this.side === 'obverse' ? 270 : 90
|
||||||
|
const newAngle = (this.rotation + rotationAngle) % 360
|
||||||
|
return new Transform(this.side, newAngle as Rotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
h(): Transform {
|
||||||
|
const newSide = this.side == 'obverse' ? 'reverse' : 'obverse'
|
||||||
|
return new Transform(newSide, this.rotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
v(): Transform {
|
||||||
|
const newSide = this.side == 'obverse' ? 'reverse' : 'obverse'
|
||||||
|
const newAngle = (this.rotation + 180) % 360
|
||||||
|
return new Transform(newSide, newAngle as Rotation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withCtx<T>(ctx: CanvasRenderingContext2D, pre: () => void, action: () => T): T {
|
||||||
|
ctx.save()
|
||||||
|
try {
|
||||||
|
pre()
|
||||||
|
return action()
|
||||||
|
} finally {
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withTransform<T>(ctx: CanvasRenderingContext2D, transform: Transform, action: () => T): T {
|
||||||
|
return withCtx(
|
||||||
|
ctx,
|
||||||
|
() => {
|
||||||
|
ctx.translate(100, 100)
|
||||||
|
if (transform.side === 'reverse') {
|
||||||
|
ctx.scale(-1, 1)
|
||||||
|
}
|
||||||
|
ctx.rotate((transform.rotation / 180) * Math.PI)
|
||||||
|
ctx.translate(-100, -100)
|
||||||
|
},
|
||||||
|
action
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { mount } from "svelte";
|
||||||
|
import "./app.css";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById("app")!,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="svelte" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||||
|
* Note that setting allowJs false does not prevent the use
|
||||||
|
* of JS in `.svelte` files.
|
||||||
|
*/
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user