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