diff --git a/apps/web/package.json b/apps/web/package.json
index f1685b1..34f1d46 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -17,6 +17,7 @@
"@elysiajs/eden": "^1.2.0",
"@lingui/core": "^5.2.0",
"@lingui/react": "^5.2.0",
+ "@sinclair/typebox": "^0.34.28",
"@workspace/schema": "workspace:*",
"@workspace/ui": "workspace:*",
"browser-fs-access": "^0.35.0",
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 5c8bdd5..77336ca 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -20,19 +20,17 @@ export function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/apps/web/src/atoms/areImagesReversedAtom.ts b/apps/web/src/atoms/areImagesReversedAtom.ts
index e59fe41..0f2be88 100644
--- a/apps/web/src/atoms/areImagesReversedAtom.ts
+++ b/apps/web/src/atoms/areImagesReversedAtom.ts
@@ -1,8 +1,11 @@
+import { createJSONStorage } from 'jotai/utils';
+import { Type } from '@sinclair/typebox';
import { atomWithToggleAndStorage } from '@/utils/atomWithToggleAndStorage';
+import { withStorageValidator } from '@/utils/withStorageValidator';
export const areImagesReversedAtom = atomWithToggleAndStorage(
'areImagesReversed',
false,
- undefined,
+ withStorageValidator(Type.Boolean())(createJSONStorage()),
{ getOnInit: true }
);
diff --git a/apps/web/src/atoms/imagesAtom.ts b/apps/web/src/atoms/imagesAtom.ts
index 9442f92..e4b939e 100644
--- a/apps/web/src/atoms/imagesAtom.ts
+++ b/apps/web/src/atoms/imagesAtom.ts
@@ -1,33 +1,39 @@
import { atom } from 'jotai';
-import { atomWithStorage, RESET, unwrap } from 'jotai/utils';
-import { readFile } from '@/utils/readFile';
+import { atomWithStorage, unwrap } from 'jotai/utils';
+import { Type } from '@sinclair/typebox';
+import { readFileAsDataURL } from '@/utils/readFile';
import { loadImage } from '@/utils/loadImage';
-import { createIndexedDbStorage } from '@/utils/createIndexedDbStorage';
+import { createIdbKeyvalStorage } from '@/utils/createIdbKeyvalStorage';
+import { withStorageValidator } from '@/utils/withStorageValidator';
-let imageId = 0;
+const ImageFileSchema = Type.Tuple([
+ Type.String({ title: 'filename' }),
+ Type.Unsafe
(Type.Any({ type: 'object' }))
+]);
-const filesAtom = atomWithStorage(
- 'files',
- [],
- createIndexedDbStorage(),
- { getOnInit: true }
-);
+const ImageFilesSchema = Type.Array(ImageFileSchema);
+
+export type ImageFile = typeof ImageFileSchema.static;
-export const imagesAtom = atom(
- async get => {
- const files = await get(filesAtom);
- return Promise.all(
- files.map(async file => {
- const dataUrl = (await readFile(r => r.readAsDataURL(file))) as string;
- const image = await loadImage(dataUrl);
- image.id = `${imageId++}`;
- return image;
- })
- );
- },
- (_get, set, files: Blob[] | typeof RESET) => {
- set(filesAtom, files);
+export const imageFilesAtom = atomWithStorage(
+ 'images',
+ [],
+ withStorageValidator(ImageFilesSchema)(createIdbKeyvalStorage()),
+ {
+ getOnInit: true
}
);
+export const imagesAtom = atom(async (get, options) => {
+ const imageFiles = await get(imageFilesAtom);
+ return Promise.all(
+ imageFiles.map(async ([id, file]) => {
+ const dataUrl = await readFileAsDataURL(file, options);
+ const image = await loadImage(dataUrl, options);
+ image.id = id;
+ return image;
+ })
+ );
+});
+
export const unwrappedImagesAtom = unwrap(imagesAtom, prev => prev ?? []);
diff --git a/apps/web/src/atoms/importAtoms.ts b/apps/web/src/atoms/importAtoms.ts
index 5011f8b..6ace456 100644
--- a/apps/web/src/atoms/importAtoms.ts
+++ b/apps/web/src/atoms/importAtoms.ts
@@ -2,9 +2,7 @@ import { atom } from 'jotai';
import { client } from '@/utils/client';
import { atomWithExpiringWriteState } from '@/utils/atomWithExpiringWriteState';
import { ImageSource } from '@/types';
-import { largestImageAtom } from './largestImageAtom';
-import { optimizeWaveformAtom } from './waveformAtoms';
-import { imagesAtom } from './imagesAtom';
+import { ImageFile, imageFilesAtom } from './imagesAtom';
const importAbortControllerAtom = atom(null);
@@ -23,15 +21,15 @@ export const importAtom = atomWithExpiringWriteState(
? imageSource
: [imageSource];
- const blobs = (
- await Promise.all(
- imageSources.map(async source => {
+ const imageFiles = (
+ await Promise.all(
+ imageSources.map>(async source => {
if (source instanceof File) {
- return [source];
+ return [[source.name, source]];
}
if (typeof source === 'string') {
const response = await fetch(source, { signal });
- return [await response.blob()];
+ return [[source, await response.blob()]];
}
const { url, deviceType } = source;
const { data, error } = await client
@@ -44,12 +42,11 @@ export const importAtom = atomWithExpiringWriteState(
throw error.value;
}
- return Object.values(data || {});
+ return Object.entries(data);
})
)
).flat();
- set(imagesAtom, blobs);
- set(optimizeWaveformAtom, await get(largestImageAtom));
+ set(imageFilesAtom, imageFiles);
}
);
diff --git a/apps/web/src/atoms/waveAtom.ts b/apps/web/src/atoms/waveAtom.ts
index e3daaf6..0440866 100644
--- a/apps/web/src/atoms/waveAtom.ts
+++ b/apps/web/src/atoms/waveAtom.ts
@@ -34,10 +34,10 @@ export const waveAtom = atom(get => {
for (let x = 0; x <= width; x++) {
const y =
- amplitude * waveFunction((2 * Math.PI * x) / wavelength) + halfHeight;
+ -amplitude * waveFunction((2 * Math.PI * x) / wavelength) + halfHeight;
ys.push(round(y - prevY));
prevY = y;
}
- return `m0 ${ys.join(' 1 ')} V0H0Z`;
+ return `m0 ${ys.join(' 1 ')} V${height}H0Z`;
});
diff --git a/apps/web/src/atoms/waveFunctionAtom.ts b/apps/web/src/atoms/waveFunctionAtom.ts
index 9622dde..6419a3c 100644
--- a/apps/web/src/atoms/waveFunctionAtom.ts
+++ b/apps/web/src/atoms/waveFunctionAtom.ts
@@ -1,10 +1,16 @@
-import { atomWithStorage } from 'jotai/utils';
+import { atomWithStorage, createJSONStorage } from 'jotai/utils';
+import { Type } from '@sinclair/typebox';
+import { withStorageValidator } from '@/utils/withStorageValidator';
import { WAVE_FUNCTION } from '@/constants';
import { WaveFunction } from '@/types';
+const WaveFunctionSchema = Type.Union(
+ Object.values(WAVE_FUNCTION).map(value => Type.Literal(value))
+);
+
export const waveFunctionAtom = atomWithStorage(
'waveFunction',
WAVE_FUNCTION.HEARTBEAT,
- undefined,
+ withStorageValidator(WaveFunctionSchema)(createJSONStorage()),
{ getOnInit: true }
);
diff --git a/apps/web/src/atoms/waveformAtoms.ts b/apps/web/src/atoms/waveformAtoms.ts
index c763f16..ef873f0 100644
--- a/apps/web/src/atoms/waveformAtoms.ts
+++ b/apps/web/src/atoms/waveformAtoms.ts
@@ -1,19 +1,25 @@
-import { atom } from 'jotai';
+import { observe } from 'jotai-effect';
import { focusAtom } from 'jotai-optics';
-import { atomWithStorage } from 'jotai/utils';
+import { atomWithStorage, createJSONStorage, RESET } from 'jotai/utils';
+import { Type } from '@sinclair/typebox';
+import { largestImageAtom } from '@/atoms/largestImageAtom';
+import { withStorageValidator } from '@/utils/withStorageValidator';
export const MIN_ROTATION = 0;
export const MAX_ROTATION = 360;
export const MIN_WAVELENGTH = 9;
export const MIN_AMPLITUDE = 0;
-export interface Waveform {
- rotation: number;
- wavelength: number;
- wavelengthMax: number;
- amplitude: number;
- amplitudeMax: number;
-}
+const WaveformSchema = Type.Object({
+ rotation: Type.Number(),
+ wavelength: Type.Number(),
+ wavelengthMax: Type.Number(),
+ amplitude: Type.Number(),
+ amplitudeMax: Type.Number(),
+ _optimized: Type.Optional(Type.Boolean())
+});
+
+export type Waveform = typeof WaveformSchema.static;
export const waveformAtom = atomWithStorage(
'waveform',
@@ -24,10 +30,14 @@ export const waveformAtom = atomWithStorage(
wavelengthMax: MIN_WAVELENGTH + 1,
amplitudeMax: MIN_AMPLITUDE + 1
},
- undefined,
+ withStorageValidator(WaveformSchema)(createJSONStorage()),
{ getOnInit: true }
);
+export const isWaveformOptimizedAtom = focusAtom(waveformAtom, optic =>
+ optic.prop('_optimized')
+);
+
// Rotation
export const rotationAtom = focusAtom(waveformAtom, optic =>
@@ -54,20 +64,37 @@ export const amplitudeMaxAtom = focusAtom(waveformAtom, optic =>
optic.prop('amplitudeMax')
);
-export const optimizeWaveformAtom = atom(
- null,
- (_get, set, image?: HTMLImageElement) => {
- const wavelengthMax = image?.width ?? MIN_WAVELENGTH + 1;
- const amplitudeMax = image
- ? Math.round(image.height / 8)
- : MIN_AMPLITUDE + 1;
+observe((get, set) => {
+ const abortController = new AbortController();
+ const largestImagePromise = get(largestImageAtom);
+ const isOptimized = get(isWaveformOptimizedAtom);
- set(waveformAtom, {
+ (async () => {
+ const largestImage = await largestImagePromise;
+ if (abortController.signal.aborted) {
+ return;
+ }
+ if (!largestImage) {
+ set(waveformAtom, RESET);
+ return;
+ }
+ if (isOptimized) {
+ return;
+ }
+ const { width, height } = largestImage;
+ const wavelengthMax = width;
+ const amplitudeMax = Math.round(height / 8);
+
+ set(waveformAtom, prev => ({
+ ...prev,
rotation: 0,
wavelength: wavelengthMax,
amplitude: amplitudeMax,
wavelengthMax,
- amplitudeMax
- });
- }
-);
+ amplitudeMax,
+ _optimized: true
+ }));
+ })();
+
+ return () => abortController.abort();
+});
diff --git a/apps/web/src/components/ChangeWaveFunction.tsx b/apps/web/src/components/ChangeWaveFunction.tsx
index 9ce3f03..91e4815 100644
--- a/apps/web/src/components/ChangeWaveFunction.tsx
+++ b/apps/web/src/components/ChangeWaveFunction.tsx
@@ -1,15 +1,15 @@
import { useAtom, useAtomValue } from 'jotai';
+import { useLingui } from '@lingui/react/macro';
import { waveFunctionAtom } from '@/atoms/waveFunctionAtom';
import {
ToggleGroup,
ToggleGroupItem
} from '@workspace/ui/components/toggle-group';
-import { Sin } from '@/components/Sin';
-import { Heartbeat } from '@/components/Heartbeat';
+import { unwrappedImagesAtom } from '@/atoms/imagesAtom';
+import { Sin } from '@/icons/Sin';
+import { Heartbeat } from '@/icons/Heartbeat';
import { WAVE_FUNCTION } from '@/constants';
import { WaveFunction } from '@/types';
-import { unwrappedImagesAtom } from '@/atoms/imagesAtom';
-import { useLingui } from '@lingui/react/macro';
export type ChanveWaveFunctionProps = Omit<
React.ComponentProps,
diff --git a/apps/web/src/components/Graphics.tsx b/apps/web/src/components/Graphics.tsx
index 747ee27..b4ab27a 100644
--- a/apps/web/src/components/Graphics.tsx
+++ b/apps/web/src/components/Graphics.tsx
@@ -56,13 +56,6 @@ export function Graphics(props: GraphicsProps) {