Initial commit

This commit is contained in:
Yura Dupyn 2026-03-02 14:04:23 +01:00
commit 9e6e6666ab
31 changed files with 3799 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist/
node_modules/

147
README.md Normal file
View file

@ -0,0 +1,147 @@
strong nuclear force (quark confinement)
the force between particles stays constant
no matter what distance
spring universe (hooks law)
- when particles are far apart, it gives a huge attractive force
BAND
- when particles are far apart, it gives attraction,
but when particles are too close, it gives repulsion,
so you have this "band" in which the force kinda drops off... and is negligable
- molecular dynamics:
- when far apart, you have very small attractive force
- when close, strong repulsive force
Galactic Gravity
- instead of newtons 1/r^2, use 1/r
- orbits around a heavy body will move at constant speed
reg
Boids
- don't crowd neighbours
- neighbours that are close have repulsive force
- alignment
What about different types of "masses"?
e.g. something like charge, or perhaps something weirder?
====Implementation====
Should I track momentum or velocity?
Since I have constant mass particle,
I can always compute velocity easily.
# Time and Evolution
Decoupling simulation step from `dt` given by the browser.
# Global attributes
- net force (should be very close to 0)
- net momentum (should be very close to a constant)
What else?
- average kinetic energy (temperature?)
- center-of-mass (should be constant)
- WTF potential energy? How to calculate that? Probably
always different for each of the systems? Does it always make sense?
# Camera & Culling
- zoom & pan
- render only those particles that are on the screen
- note that this is gonna fuck up the trails, but whatever
# Brushes
## Spawning
- spray paint - bunch of particles near each other but with non-zero initial velocity
- single heavy click brush spawning particles
- brush that spawns particles orthogonal to the center of mass
## Filtering
- delete particles in mass range
- delete particles in region
## Mapping
- change mass of all particles based on a function.
## Reduce
- Replace particles in a region by one particle that has combined mass
# Local Attributes
- follow a single particle and highlight it, name it, display its attributes
- Select a square/circle s.t. all particles within it
are now tracked... or perhaps those that enter/exit it?
# Dimensions/Layers?
What about having quantities such as a `phase`?
Where basically particles of different phases don't interact... but then we could change phases of particles, and suddenly there is interaction etc...
# Symplectic vs Explicit Euler
Suppose we have `(pos0, vel0)`, and we have `dt`. We compute `acc` (acceleration).
Then the enxt state is:
- Explicit:
```
(pos0 + dt*vel0, vel0 + dt*acc)
```
- Symplectic:
```
let {
vel1 = vel0 + dt*acc,
pos1 = pos0 + dt*vel1,
.
(pos1, vel1)
}
```
I have no idea why symplectic works...
Does it work only for some systems or all?
# Energy
- Kinetic Energy is the energy of motion...
- Potential Energy is the energy of position/configuration (relative position? idk...)
Compute? Potential Energy is integral of force over distance...
Hmm. Let's have `f : M -> R` the "potential".
The force field that's generated by `f` is `d(f)` - but actually... the gradient?.
But given a force field, how can we attempt to
compute the potential energy? Atleast on the trajectory of the particle...
There's no unique solution... so let's say we spawn a particle, and give it potential of 0.
Then the acceleartion field tells us in which direction to move. I guess we're computing an integral over some 1-form. How do we get the 1-form?
It is at a point, the inner product of the infinitesimal velocity with the infinitesimal force...
```
<dv|df>
```
# Angular Momentum?
What if our basic element would be like "rods" of two particles where the constraint is that the distance between them is fixed. Then we could naturally have
angular velocity etc...

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

11
index.html Normal file
View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Newtonian Dance</title>
</head>
<body>
<script type="module" src="src/main.tsx"></script>
</body>
</html>

BIN
notes_0.kra Executable file

Binary file not shown.

BIN
notes_0.kra~ Executable file

Binary file not shown.

1937
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "newtonian_dance",
"version": "1.0.0",
"description": "",
"license": "MIT",
"author": "Yuriy Dupyn",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "tsc && vite build --base ./",
"preview": "vite preview"
},
"devDependencies": {
"@types/node": "^25.3.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
},
"dependencies": {
"solid-js": "^1.9.11"
}
}

97
src/App.tsx Normal file
View file

@ -0,0 +1,97 @@
import { createSignal, For, Switch, Match } from "solid-js";
import { FlatGravitySystem } from "./systems/flat_gravity";
import { FlatGravityView, ViewConfig as FlatGravityViewConfig } from "./systems/flat_gravity_solid";
import { SimpleKinematicsSystem } from "./systems/simple_kinematics";
import { SimpleKinematicsView } from "./systems/simple_kinematics_solid";
const systemIds = [
"simple-kinematics",
"flat-gravity",
] as const;
export type SystemId = (typeof systemIds)[number];
const defaultSystemId: SystemId = "flat-gravity";
const systemToLabel: Record<SystemId, string> = {
"simple-kinematics": "Simple Kinematics",
"flat-gravity": "Flat Gravity",
};
export function FlatGravityHost() {
const flatGravity = FlatGravitySystem({
dt: 16,
topLeft: { x: 200, y: 200 },
bottomRight: { x: 600, y: 600 },
maxSpeed: 100 / 1000,
countSmall: 200,
countBig: 3,
massRangeSmall: { min: 0.1, max: 0.5 },
massRangeBig: { min: 200.0, max: 300.0 },
});
const viewConfig: FlatGravityViewConfig = {
massRange: { min: 0, max: 300 },
radiusRange: { min: 2, max: 20 },
expectedNetMass: 20000,
};
return <FlatGravityView system={flatGravity} viewConfig={viewConfig} />;
}
export function SimpleKinematicsHost() {
const simpleKinematics = SimpleKinematicsSystem({
dt: 4,
count: 500,
topLeft: { x: 200, y: 200 },
bottomRight: { x: 400, y: 400 },
maxSpeed: 200 / 1000,
});
return <SimpleKinematicsView system={simpleKinematics} />;
}
function SystemView(props: { systemId: SystemId }) {
return (
<Switch fallback={<div>Select a system</div>}>
<Match when={props.systemId === "flat-gravity"}>
<FlatGravityHost />
</Match>
<Match when={props.systemId === "simple-kinematics"}>
<SimpleKinematicsHost />
</Match>
</Switch>
);
}
export function App() {
const [systemId, setSystemId] = createSignal<SystemId>(defaultSystemId);
return (
<div style="display: flex; flex-direction: column; height: 100vh;">
<header class="top-nav">
<div style="display: flex; align-items: center; gap: 8px;">
<label for="system-select">System:</label>
<select
id="system-select"
class="system-dropdown"
value={systemId()}
onInput={ e => setSystemId(e.currentTarget.value as SystemId) }
>
<For each={ systemIds }>
{ (id) => (
<option value={id}>{ systemToLabel[id] }</option>
) }
</For>
</select>
</div>
</header>
<main>
<SystemView systemId={systemId()} />
</main>
</div>
);
}

217
src/arithmetic.ts Normal file
View file

@ -0,0 +1,217 @@
// CONVENTIONS:
// - scalars are: A, B, C, ...
// - vectors are: V, W, U, ...
// - points are: P, Q, R
// - numbers are: a, b, c, ..., s, t,..., u, v, w,..., x, y, z, ...
export type Scalar = {
val: Float64Array,
}
export type Vector = {
x: Float64Array,
y: Float64Array,
}
export type Config = {
capacity: number,
count: number,
}
export function Arithmetic({ capacity: CAPACITY, count: COUNT }: Config) {
const Scalar = {
allocate(): Scalar {
return {
val: new Float64Array(CAPACITY),
};
},
// Out := 0
zero(Out: Scalar) {
for (let i = 0; i < COUNT; i++) {
Out.val[i] = 0;
}
},
// Out := A + B
add(Out: Scalar, A: Scalar, B: Scalar) {
for (let i = 0; i < COUNT; i++) {
Out.val[i] = A.val[i] + B.val[i];
}
},
// Out := A * B
mul(Out: Scalar, A: Scalar, B: Scalar) {
for (let i = 0; i < COUNT; i++) {
Out.val[i] = A.val[i] * B.val[i];
}
},
// Out := A + s*B
addScaledByConstant(Out: Scalar, A: Scalar, s: number, B: Scalar) {
for (let i = 0; i < COUNT; i++) {
Out.val[i] = A.val[i] + s*B.val[i];
}
},
// Out := A / B
div(Out: Scalar, A: Scalar, B: Scalar) {
for (let i = 0; i < COUNT; i++) {
Out.val[i] = A.val[i] / B.val[i];
}
},
// Out := 1/A
reciprocal(Out: Scalar, A: Scalar) {
for (let i = 0; i < COUNT; i++) {
Out.val[i] = 1/A.val[i];
}
},
// Out := A*s
scaleByNumber(Out: Scalar, A: Scalar, s: number) {
for (let i = 0; i < COUNT; i++) {
Out.val[i] = A.val[i] * s;
}
},
sum(A: Scalar): number {
let sum = 0;
for (let i = 0; i < COUNT; i++) {
sum += A.val[i];
}
return sum;
},
average(A: Scalar): number {
// TODO: Perhaps not the best method... might cause overflows...?
return this.sum(A)/COUNT;
},
// Do I need a general `map`? Mapping one `number => number` over each scalar value in the scalar field
// It will not be performant. Better to write the loop by hand in JS.
};
const Vector = {
allocate(): Vector {
return {
x: new Float64Array(CAPACITY),
y: new Float64Array(CAPACITY),
};
},
// Out := 0
zero(Out: Vector) {
for (let i = 0; i < COUNT; i++) {
Out.x[i] = 0;
Out.y[i] = 0;
}
},
// Out := V + W
add(Out: Vector, V: Vector, W: Vector) {
// in js a single loop wins (bounds checking, dealing with `i` etc). But in something like C or WASM two loops might actually be better (better patterns of access to memory)
for (let i = 0; i < COUNT; i++) {
Out.x[i] = V.x[i] + W.x[i];
Out.y[i] = V.y[i] + W.y[i];
}
},
// Out := V + s*W
addScaledByConstant(Out: Vector, V: Vector, s: number, W: Vector) {
for (let i = 0; i < COUNT; i++) {
Out.x[i] = V.x[i] + s*W.x[i];
Out.y[i] = V.y[i] + s*W.y[i];
}
},
// Out := V*s
scaleByConstant(Out: Vector, V: Vector, s: number) {
for (let i = 0; i < COUNT; i++) {
Out.x[i] = V.x[i] * s;
Out.y[i] = V.y[i] * s;
}
},
// Out := V*S
scaleByScalar(Out: Vector, V: Vector, S: Scalar) {
for (let i = 0; i < COUNT; i++) {
Out.x[i] = V.x[i] * S.val[i];
Out.y[i] = V.y[i] * S.val[i];
}
},
// Out := V / S
divideByScalar(Out: Vector, V: Vector, S: Scalar) {
for (let i = 0; i < COUNT; i++) {
Out.x[i] = V.x[i] / S.val[i];
Out.y[i] = V.y[i] / S.val[i];
}
},
// Out := <V, W>
innerProduct(Out: Scalar, V: Vector, W: Vector) {
for (let i = 0; i < COUNT; i++) {
Out.val[i] = V.x[i]*W.x[i] + V.y[i]*W.y[i];
}
},
// Out := |V|
magnitude(Out: Scalar, V: Vector) {
for (let i = 0; i < COUNT; i++) {
const x = V.x[i];
const y = V.y[i];
Out.val[i] = Math.sqrt(x*x + y*y);
}
},
// Out := V/|V|
normalize(Out: Vector, V: Vector) {
for (let i = 0; i < COUNT; i++) {
const x = V.x[i];
const y = V.y[i];
const s = Math.sqrt(x*x + y*y);
Out.x[i] = x / s;
Out.y[i] = y / s;
}
},
sum(V: Vector): { x: number, y: number } {
let x: number = 0;
let y: number = 0;
for (let i = 0; i < COUNT; i++) {
x += V.x[i];
y += V.y[i];
}
return { x, y };
},
average(V: Vector): { x: number, y: number } {
const v = this.sum(V);
v.x = v.x/COUNT;
v.y = v.y/COUNT;
return v;
},
// TODO: Later we can consider other things like wedge product, or rotation by 90... or general rotation etc.
}
function setCount(newCount: number) {
COUNT = newCount;
}
function getCount(): number {
return COUNT;
}
// TODO: Later also implement resizing capabilities that change Capacity.
return {
Scalar,
Vector,
setCount,
getCount,
}
}

45
src/draw.ts Normal file
View file

@ -0,0 +1,45 @@
export namespace Draw {
export function particle(
ctx: CanvasRenderingContext2D,
x: number, y: number,
radius: number,
fill: string,
stroke = "lightblue"
) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = stroke;
ctx.lineWidth = 1;
ctx.stroke();
}
export function crosshair(
ctx: CanvasRenderingContext2D,
x: number, y: number,
size: number,
color: string,
) {
const circleRadius = size * 0.5;
ctx.strokeStyle = color;
ctx.lineWidth = 2; // Fixed thickness
// The Cross
ctx.beginPath();
// Horizontal line
ctx.moveTo(x - size, y);
ctx.lineTo(x + size, y);
// Vertical line
ctx.moveTo(x, y - size);
ctx.lineTo(x, y + size);
ctx.stroke();
// The Circle
ctx.beginPath();
ctx.arc(x, y, circleRadius, 0, Math.PI * 2);
ctx.stroke();
}
}

57
src/helpers.ts Normal file
View file

@ -0,0 +1,57 @@
import { createSignal } from "solid-js";
export function random(a: number, b: number) {
const intervalSize = b - a;
return a + Math.random()*intervalSize;
}
export function projectToInterval(a: number, x: number, b: number) {
return Math.min(Math.max(a, x), b)
}
export function mapRange(value: number, inMin: number, inMax: number, outMin: number, outMax: number) {
const t = (value - inMin) / (inMax - inMin);
const clampedT = projectToInterval(0, t, 1);
return outMin + clampedT * (outMax - outMin);
}
// Snaps microscopic floating point errors to 0, and rounds to 2 decimals
export function formatNumber(x: number): string {
if (Math.abs(x) < 1e-10) return "0.00";
return x.toFixed(2);
}
// === Time ===
export type MicroDuration = number;
export type Duration = number;
// === Events ===
export function usePointer(ref: () => HTMLCanvasElement) {
const [position, setPosition] = createSignal([0, 0]);
const [isDown, setIsDown] = createSignal(false);
const handle = (e: PointerEvent) => {
const rect = ref().getBoundingClientRect();
setPosition([e.clientX - rect.left, e.clientY - rect.top]);
setIsDown(e.buttons === 1);
};
return { position, isDown, handle };
}
// === Generic System ===
// Observables contain derived attributes that can be computed purely from the `State`.
export interface System<State, Observables, Capabilities> {
state: State,
observables: Observables,
flow(t: Duration): void, // Updates just the state, not observables!
refreshObservables(): void, // Updates just the observables from the current state.
totalTime(): Duration, // gets the time from the start of the simulation
reset(): void,
capabilities: Capabilities,
}

9
src/main.tsx Normal file
View file

@ -0,0 +1,9 @@
import "./styles/main.css";
import { render } from "solid-js/web";
import { App } from "./App";
// === Coordinate Transformations ===
// TODO: canvas to system coordinates and the other way
render(() => <App />, document.body);

129
src/styles/app.css Normal file
View file

@ -0,0 +1,129 @@
main {
margin-top: 15px;
}
.app-layout {
display: grid;
/* Columns: Brushes | Canvas (800px) | Observables */
grid-template-columns: 100px 800px 400px;
/* Rows: Controls | Main content */
grid-template-rows: min-content min-content;
gap: 5px;
background-color: var(--bg-app);
min-height: 100vh;
justify-content: center;
align-content: start;
}
.controls {
grid-column: 2; /* Center column */
display: flex;
align-items: center;
gap: 15px;
color: var(--text-main);
padding-bottom: 10px;
}
.canvas-container {
grid-column: 2;
height: 600px; /* Force the canvas height here */
}
.canvas-container canvas {
background: #000;
cursor: crosshair;
border-radius: 4px;
border: 1px solid var(--border-subtle);
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
touch-action: none;
}
.brushes-panel {
grid-column: 1;
grid-row: 2; /* Sits next to the canvas */
align-self: start;
}
.side-panel {
grid-column: 3;
grid-row: 2; /* Sits next to the canvas */
align-self: start;
}
/* --- Buttons --- */
.btn {
background: var(--bg-panel);
color: var(--text-main);
border: 1px solid var(--border-subtle);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
}
/* --- Checkboxes & Labels --- */
.toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
user-select: none;
color: var(--text-dim);
}
.toggle input[type="checkbox"] {
cursor: pointer;
accent-color: var(--accent-sub-label); /* Modern browsers will use your blue for the check */
}
/* --- Top Stats --- */
.top-stats {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
display: inline-block;
min-width: 50px;
}
.top-nav {
display: flex;
align-items: center;
background-color: var(--bg-toolbar);
border-bottom: 1px solid var(--border-subtle);
padding: 0 10px;
font-size: 12px;
color: var(--text-toolbar);
}
/* Simple Select Styling */
.system-dropdown {
background-color: var(--bg-panel);
color: var(--text-main);
border: 1px solid var(--border-subtle);
border-radius: 3px;
padding: 1px 4px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.system-dropdown:hover {
border-color: var(--accent-sub-label);
}
.system-dropdown:focus {
outline: 1px solid var(--accent-sub-label);
}

18
src/styles/main.css Normal file
View file

@ -0,0 +1,18 @@
@import "./variables.css";
@import "./observables.css";
@import "./app.css";
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background-color: var(--bg-app);
color: var(--text-main);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}

View file

@ -0,0 +1,37 @@
.observables-container {
column-gap: 12px;
row-gap: 6px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
background: var(--bg-panel);
color: var(--text-main);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--border-subtle);
}
.observables-header {
padding-top: 12px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.observables-label {
color: var(--accent-label);
}
.observables-component-label {
color: var(--accent-sub-label);
text-align: right;
}
.observables-value {
color: var(--color-number);
text-align: right;
}

20
src/styles/variables.css Normal file
View file

@ -0,0 +1,20 @@
:root {
/* Surface Colors */
--bg-app: #121212;
--bg-panel: #1a1a1a;
--border-subtle: #333333;
/* Typography & Accents */
--text-main: #e0e0e0;
--text-dim: #888888;
--accent-label: #9cdcfe; /* Soft Blue */
--accent-sub-label: #569cd6; /* Deeper Blue */
/* Data Colors */
--color-number: #b5cea8; /* Pale Green */
--color-critical: #f44747; /* Red */
/* Toolbar specific */
--bg-toolbar: #252526;
--text-toolbar: #cccccc;
}

346
src/systems/flat_gravity.ts Normal file
View file

@ -0,0 +1,346 @@
// A particle creates an attractive force field towards it s.t. the strength is proportional to the particle's mass.
// It doesn't depend on the distance - the force is constant.
import { Arithmetic, Vector, Scalar } from "../arithmetic";
import { Duration, MicroDuration, random, System } from "../helpers";
export type Config = {
dt: MicroDuration, // e.g. 5 miliseconds
// Box in which to generate random particles with uniform distribution, and random velocities
topLeft: {x: number, y: number},
bottomRight: {x: number, y: number},
maxSpeed: number,
countSmall: number,
massRangeSmall: { min: number, max: number },
countBig: number,
massRangeBig: { min: number, max: number },
}
export type State = {
position: Vector,
velocity: Vector,
mass: Scalar,
}
export type Observables = {
particleCount: number,
averageSpeed: number,
netMass: number,
netForce: { fx: number, fy: number },
netMomentum: { px: number, py: number },
centerOfMass: { x: number, y: number },
netKineticEnergy: number,
netPotentialEnergy: number,
netPotentialEnergyIntegralCalculation: number,
netEnergy: number,
}
export type Capabilities = {
spawn(x: number, y: number, vx: number, vy: number, m: number): void,
}
export type FlatGravitySystem = System<State, Observables, Capabilities>;
export function FlatGravitySystem(config: Config): FlatGravitySystem {
const arith = Arithmetic({ count: 0, capacity: 10000 });
const { Vector, Scalar } = arith;
// Responsible for computing the force/acceleration fields
function Force() {
const force = Vector.allocate();
const acceleration = Vector.allocate();
function LOOM() {
const count = arith.getCount();
Vector.zero(force);
Vector.zero(acceleration);
for (let i = 0; i < count; i++) {
// Compute the net acceleration on i coming from interaction with all the other particles.
for (let j = i + 1; j < count; j++) {
const mi = mass.val[i];
const mj = mass.val[j];
const dx = position.x[j] - position.x[i];
const dy = position.y[j] - position.y[i];
const size = Math.sqrt(dx*dx + dy*dy);
if (size == 0) { continue }
const ux = dx/size;
const uy = dy/size;
// [ux, uy] is the unit vector pointing from i to j
const ax_i = mj*G*ux;
const ay_i = mj*G*uy;
const fx_i = mi*ax_i;
const fy_i = mi*ay_i;
const ax_j = -mi*G*ux;
const ay_j = -mi*G*uy;
acceleration.x[i] += ax_i;
acceleration.y[i] += ay_i;
acceleration.x[j] += ax_j;
acceleration.y[j] += ay_j;
force.x[i] += fx_i;
force.y[i] += fy_i;
force.x[j] += -fx_i;
force.y[j] += -fy_i;
}
}
}
return { force, acceleration, LOOM };
}
// TODO: Better name
// Responsible for stepping (position, velocity)
function MicroEvolution() {
const microDisplacement: Vector = Vector.allocate();
function LOOM(dt: MicroDuration) {
// Symplectic Euler:
// velocity = velocity + dt*acceleration
// position = position + dt*velocity
//
// Explicit Euler (seems to increase total energy over time):
// position = position + dt*velocity
// velocity = velocity + dt*acceleration
Vector.addScaledByConstant(velocity, velocity, dt, acceleration);
// If you don't need micro-displacement, you can just use:
// Vector.addScaledByConstant(position, position, dtInSeconds, velocity);
Vector.scaleByConstant(microDisplacement, velocity, dt);
Vector.add(position, position, microDisplacement);
}
return { microDisplacement, LOOM };
}
// Updates potential energy by current forces
function PotentialEnergyUpdate() {
const potentialEnergy: Scalar = Scalar.allocate();
const microWork: Scalar = Scalar.allocate();
function LOOM(force: Vector, microDisplacement: Vector) {
// U := U - <F, microDisplacement>
Vector.innerProduct(microWork, force, microDisplacement);
Scalar.addScaledByConstant(potentialEnergy, potentialEnergy, -1, microWork);
}
return { potentialEnergy, LOOM };
}
function NetKineticEnergy() {
const kineticEnergy: Scalar = Scalar.allocate();
const velocitySquared: Scalar = Scalar.allocate();
const netKineticEnergy = { value: 0 };
function LOOM(mass: Scalar, velocity: Vector) {
// totalKineticEnergy = 1/2 * sum(m*v^2)
Vector.innerProduct(velocitySquared, velocity, velocity);
Scalar.mul(kineticEnergy, mass, velocitySquared);
netKineticEnergy.value = 0.5*Scalar.sum(kineticEnergy);
}
return { netKineticEnergy, LOOM };
}
function SetMomentumAndCenterOfMass() {
const momentum: Vector = Vector.allocate();
const weightedPosition: Vector = Vector.allocate();
const centerOfMass = { x: 0, y: 0 };
const netMass = { value: 0 };
function LOOM(mass: Scalar, velocity: Vector) {
Vector.scaleByScalar(momentum, velocity, mass);
netMass.value = Scalar.sum(mass);
// centerOfMass := sum(mass * position) / totalMass
Vector.scaleByScalar(weightedPosition, position, mass);
const { x: mx, y: my } = Vector.sum(weightedPosition);
if (netMass.value !== 0) {
centerOfMass.x = mx/netMass.value;
centerOfMass.y = my/netMass.value;
}
}
return { momentum, centerOfMass, LOOM }
}
let time: Duration = 0;
const state: State = {
position: Vector.allocate(), // in pixels
velocity: Vector.allocate(), // in pixels per second (not miliseconds)
mass: Scalar.allocate(),
};
const { velocity, position, mass } = state;
const G = 0.05;
const { force, acceleration, LOOM: forceLoom } = Force();
const { microDisplacement, LOOM: microEvolutionLoom } = MicroEvolution();
const { potentialEnergy, LOOM: potentialEnergyUpdateLoom } = PotentialEnergyUpdate();
const { netKineticEnergy, LOOM: netKineticEnergyLoom } = NetKineticEnergy();
const { momentum, centerOfMass, LOOM: setMomentumAndCenterOfMassLoom } = SetMomentumAndCenterOfMass();
const weightedPosition: Vector = Vector.allocate();
const speed: Scalar = Scalar.allocate();
function spawn(x: number, y: number, vx: number, vy: number, m: number) {
const idx = arith.getCount();
position.x[idx] = x;
position.y[idx] = y;
velocity.x[idx] = vx;
velocity.y[idx] = vy;
state.mass.val[idx] = m;
arith.setCount(idx + 1);
}
function reset() {
arith.setCount(0);
for (let i = 0; i < config.countBig; i++) {
spawn(
// Random position inside the bounding box
random(config.topLeft.x, config.bottomRight.x),
random(config.topLeft.y, config.bottomRight.y),
// Random velocity between -maxVelocity and +maxVelocity
random(-1, 1) * config.maxSpeed,
random(-1, 1) * config.maxSpeed,
// Random mass
random(config.massRangeBig.min, config.massRangeBig.max),
);
}
for (let i = 0; i < config.countSmall; i++) {
spawn(
// Random position inside the bounding box
random(config.topLeft.x, config.bottomRight.x),
random(config.topLeft.y, config.bottomRight.y),
// Random velocity between -maxVelocity and +maxVelocity
random(-1, 1) * config.maxSpeed,
random(-1, 1) * config.maxSpeed,
// Random mass
random(config.massRangeSmall.min, config.massRangeSmall.max),
);
}
Scalar.zero(potentialEnergy);
}
reset();
// TODO: how to initialize this without code-duplication?
const observables: Observables = {
particleCount: 0,
averageSpeed: 0,
netMass: 0,
netForce: { fx: 0, fy: 0 },
netMomentum: { px: 0, py: 0 },
centerOfMass: { x: 0, y: 0 },
netKineticEnergy: 0,
netPotentialEnergy: 0,
netPotentialEnergyIntegralCalculation: 0,
netEnergy: 0,
};
function refreshObservables() {
const count = arith.getCount();
observables.particleCount = count;
if (count === 0) return;
Vector.magnitude(speed, velocity);
// Note that this would produce division by zero for `count === 0`.
observables.averageSpeed = Scalar.average(speed);
// netMass := sum(mass)
const netMass = Scalar.sum(mass);
observables.netMass = netMass;
// netForce := sum(force)
const { x: fx, y: fy } = Vector.sum(force);
observables.netForce.fx = fx;
observables.netForce.fy = fy;
setMomentumAndCenterOfMassLoom(mass, velocity);
const { x: px, y: py } = Vector.sum(momentum);
observables.netMomentum.px = px;
observables.netMomentum.py = py;
observables.centerOfMass.x = centerOfMass.x;
observables.centerOfMass.y = centerOfMass.y;
netKineticEnergyLoom(mass, velocity);
observables.netKineticEnergy = netKineticEnergy.value;
// Potential energy
let totalPotential = 0;
for (let i = 0; i < count; i++) {
for (let j = i + 1; j < count; j++) {
const dx = position.x[j] - position.x[i];
const dy = position.y[j] - position.y[i];
const distance = Math.sqrt(dx * dx + dy * dy);
// U = G * m1 * m2 * distance
totalPotential += G * mass.val[i] * mass.val[j] * distance;
}
}
observables.netPotentialEnergy = totalPotential;
observables.netEnergy = observables.netKineticEnergy + observables.netPotentialEnergy;
observables.netPotentialEnergyIntegralCalculation = Scalar.sum(potentialEnergy);
}
function microFlow(dt: MicroDuration) {
forceLoom();
const dtInSeconds = dt / 1000.0;
microEvolutionLoom(dtInSeconds);
potentialEnergyUpdateLoom(force, microDisplacement);
}
// Suppose that we need to flow for 43 miliseconds, and dt is 10 miliseconds.
// Then we do micro-flow 4 times totalling 40 miliseconds. We don't do micro-flow for 3 seconds
// - that could cause problems with integration.
// Instead we remember this leftover time, so the next time we flow, we add this leftover time to the total time to flow.
let leftover_time: Duration = 0;
// Integrates the micro-flow, updates global time, and recomputes the observables.
function flow(t: Duration) {
// TODO: They way we compute leftover time is not so great. There must be a better way.
const end_time = time + t + leftover_time;
while (true) {
const next_time = time + config.dt;
if (next_time <= end_time) {
time = next_time;
microFlow(config.dt);
} else {
// assume at the start
// dt = 10 ms
//
// time = 0
// leftover_time = 0
// t = 43 ms
// then here
// next_time = 50 ms
// time = 40 ms
// end_time = 43 ms
// so actually we have 3 miliseconds leftover, i.e. we set
leftover_time = end_time - time;
break;
}
}
}
return {
state,
observables,
flow,
refreshObservables,
totalTime() { return time; },
reset,
capabilities: {
spawn,
},
};
}

View file

@ -0,0 +1,245 @@
import { Config, State, Observables, Capabilities, FlatGravitySystem } from "./flat_gravity";
import { Duration, mapRange, random, usePointer } from "../helpers";
import { createEffect, createSignal, onMount } from "solid-js";
import { ObservableEntry, ObservablesPanel } from "../ui_helpers";
import { Draw } from "../draw";
import { useSimulation } from "../useSimulation";
export type ViewConfig = {
massRange: { min: number, max: number },
radiusRange: { min: number, max: number },
expectedNetMass: number,
}
function entries(obs: Observables): ObservableEntry[] {
return [
{ type: "header", label: "General" },
{ type: "scalar", label: "Particles", value: obs.particleCount },
{ type: "scalar", label: "Net Mass", value: obs.netMass },
{ type: "header", label: "Energy" },
{ type: "scalar", label: "Kinetic", value: obs.netKineticEnergy },
{ type: "scalar", label: "Potential (formula)", value: obs.netPotentialEnergy },
{ type: "scalar", label: "Potential (integral)", value: obs.netPotentialEnergyIntegralCalculation },
{ type: "scalar", label: "formula - integral", value: obs.netPotentialEnergy - obs.netPotentialEnergyIntegralCalculation },
{ type: "scalar", label: "Total (formula)", value: obs.netEnergy },
{ type: "scalar", label: "Total (integral)", value: obs.netKineticEnergy + obs.netPotentialEnergyIntegralCalculation },
{ type: "header", label: "Vectors" },
{
type: "vector",
label: "Center of Mass",
vector: [ obs.centerOfMass.x, obs.centerOfMass.y ],
components: ["x", "y"]
},
{
type: "vector",
label: "Net Force",
vector: [ obs.netForce.fx, obs.netForce.fy ],
components: ["fx", "fy"]
},
{
type: "vector",
label: "Net Momentum",
vector: [ obs.netMomentum.px, obs.netMomentum.py ],
components: ["px", "py"]
},
];
}
function render(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
system: FlatGravitySystem,
{ massRange, radiusRange, expectedNetMass }: ViewConfig,
showCenterOfMass: boolean,
) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { particleCount, centerOfMass, netMass } = system.observables;
const { position, mass } = system.state;
for (let i = 0; i < particleCount; i++) {
const m = mass.val[i];
const radius = mapRange(m, massRange.min, massRange.max, radiusRange.min, radiusRange.max);
// Interpolate Color (Blue = 240, Red = 0)
const hue = mapRange(m, massRange.min, massRange.max, 240, 0);
Draw.particle(ctx, position.x[i], position.y[i], radius, `hsl(${hue}, 100%, 50%)`);
}
if (showCenterOfMass && particleCount > 0) {
const radius = mapRange(netMass, 0, expectedNetMass, 0, 300);
Draw.crosshair(ctx, centerOfMass.x, centerOfMass.y, radius, "red");
}
}
function renderTrails(
ctx: CanvasRenderingContext2D,
system: FlatGravitySystem,
config: ViewConfig,
opacity: number = 0.03
) {
ctx.fillStyle = `rgba(0, 0, 0, ${opacity})`;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const { particleCount } = system.observables;
const { position, mass } = system.state;
const { massRange } = config;
ctx.fillStyle = "rgba(255, 255, 255, 0.5)"; // Faint white dots
for (let i = 0; i < particleCount; i++) {
const m = mass.val[i];
const trailSize = mapRange(m, massRange.min, massRange.max, 1, 3);
ctx.fillRect(
position.x[i] - trailSize / 2,
position.y[i] - trailSize / 2,
trailSize,
trailSize
);
}
}
export function FlatGravityView(props: { system: FlatGravitySystem, viewConfig: ViewConfig }) {
const [uiState, setUiState] = createSignal<Observables>(structuredClone(props.system.observables));
const [showCenterOfMass, setShowCenterOfMass] = createSignal(true);
const [showTrails, setShowTrails] = createSignal(true);
const width = 800;
const height = 600;
let canvasRef!: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let trailCanvasRef!: HTMLCanvasElement;
let trailCtx: CanvasRenderingContext2D;
onMount(() => {
ctx = canvasRef.getContext("2d")!;
trailCtx = trailCanvasRef.getContext("2d")!;
// Stop the 'ghost image' drag behavior
canvasRef.addEventListener('dragstart', (e) => e.preventDefault());
// prevent the context menu on right-click
// if you plan to use right-click for panning later
// canvasRef.addEventListener('contextmenu', (e) => e.preventDefault());
});
const { fps, isPlaying, togglePlay, requestSyncedRender } = useSimulation({
step(dt: Duration) {
props.system.flow(dt);
},
render() {
props.system.refreshObservables();
if (showTrails()) {
renderTrails(trailCtx, props.system, props.viewConfig);
}
render(canvasRef, ctx, props.system, props.viewConfig, showCenterOfMass());
},
sync() {
setUiState(structuredClone(props.system.observables));
},
isPlaying: true,
});
const { position, isDown, handle: handlePointer } = usePointer(() => canvasRef);
function toggleTrails() {
if (showTrails()) {
// Clear the trail canvas entirely when turned off
trailCtx.clearRect(0, 0, trailCanvasRef.width, trailCanvasRef.height);
setShowTrails(false);
} else {
setShowTrails(true);
}
}
// spawn new particles on mouse-down
createEffect(() => {
if (isDown()) {
if (props.system.observables.particleCount >= 10000) return;
const [x, y] = position();
// Add a tiny bit of random scatter so they don't perfectly overlap.
const rx = x + random(-5, 5);
const ry = y + random(-5, 5);
// Spawn a small particle with zero initial velocity (note that net-momentum is not gonna change)
const m = random(1, 5);
props.system.capabilities.spawn(rx, ry, 0, 0, m);
requestSyncedRender();
}
});
return (
<div class="app-layout">
<div class="cell-0"></div>
{/* === Top Controls === */}
<div class="cell-1 controls">
<button class="btn" onClick={togglePlay} style="width: 70px">
{isPlaying() ? "Pause" : "Play"}
</button>
<button class="btn" onClick={props.system.reset} style="width: 70px">
Reset
</button>
<span class="top-stats">
FPS: {fps()}
</span>
<label class="toggle">
<input
type="checkbox"
checked={showCenterOfMass()}
onChange={(e) => setShowCenterOfMass(e.currentTarget.checked)}
/>
Show Center-of-Mass
</label>
<label class="toggle">
<input
type="checkbox"
checked={showTrails()}
onChange={() => toggleTrails() }
/>
Show trails
</label>
</div>
<div class="cell-2"></div>
{/* === Reserved for future brushes === */}
<div class="cell-3 brushes-panel">
</div>
{/* === Canvas === */}
<div class="cell-4 canvas-container">
<div style="position: relative">
<canvas
ref={trailCanvasRef}
width={width} height={height}
style={{ position: "absolute" }}
/>
<canvas
ref={canvasRef}
width={width} height={height}
onPointerDown={handlePointer}
onPointerMove={handlePointer}
style={{ position: "absolute", background: "transparent" }}
/>
</div>
</div>
{/* === Observables === */}
<div class="cell-5 side-panel">
<ObservablesPanel data={ entries(uiState()) } />
</div>
</div>
);
}

View file

@ -0,0 +1,150 @@
import { Arithmetic, Vector, Scalar } from "../arithmetic";
import { Duration, MicroDuration, random, System } from "../helpers";
// read-only config used to initialize the system
export type Config = {
dt: MicroDuration, // e.g. 5 miliseconds
count: number,
// Box in which to generate random particles with uniform distribution, and random velocities
topLeft: {x: number, y: number},
bottomRight: {x: number, y: number},
maxSpeed: number,
}
export type State = {
position: Vector,
velocity: Vector,
}
export type Observables = {
particleCount: number,
averageSpeed: number,
// bounding-box
topLeft: {x: number, y: number},
bottomRight: {x: number, y: number},
}
export type SimpleKinematicsSystem = System<State, Observables, {}>;
export function SimpleKinematicsSystem(config: Config): SimpleKinematicsSystem {
const arith = Arithmetic({ count: 0, capacity: 1000 });
const { Vector, Scalar } = arith;
let time: Duration = 0;
const state: State = {
position: Vector.allocate(),
velocity: Vector.allocate(),
};
const { velocity, position } = state;
function reset() {
arith.setCount(config.count);
const count = arith.getCount();
for (let i = 0; i < count; i++) {
// Random position inside the bounding box
position.x[i] = random(config.topLeft.x, config.bottomRight.x);
position.y[i] = random(config.topLeft.y, config.bottomRight.y);
// Random velocity between -maxVelocity and +maxVelocity
velocity.x[i] = random(-1, 1) * config.maxSpeed;
velocity.y[i] = random(-1, 1) * config.maxSpeed;
}
}
reset();
// TODO: how to initialize this without code-duplication?
const observables: Observables = {
// TODO: Is there a better way of doing this?
// These are dummy values that will be overwritten
particleCount: 0,
averageSpeed: 0,
topLeft: {x: 0, y: 0},
bottomRight: {x: 0, y: 0},
};
const speed: Scalar = Scalar.allocate();
function refreshObservables() {
const count = arith.getCount();
observables.particleCount = count;
if (count === 0) return;
// bounding-box
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i < count; i++) {
const x = position.x[i];
const y = position.y[i];
if (x < minX) { minX = x; }
if (x > maxX) { maxX = x; }
if (y < minY) { minY = y; }
if (y > maxY) { maxY = y; }
}
observables.topLeft.x = minX;
observables.topLeft.y = minY;
observables.bottomRight.x = maxX;
observables.bottomRight.y = maxY;
Vector.magnitude(speed, velocity);
// Note that this would produce division by zero for `count === 0`.
observables.averageSpeed = Scalar.average(speed);
}
// const acceleration: Vector = Vector.allocate();
// const force: Vector = Vector.allocate();
// const momentum: Vector = Vector.allocate();
// const kineticEnergy: Scalar = Vector.allocate();
function microFlow(dt: MicroDuration) {
// position = position + dt*velocity
Vector.addScaledByConstant(position, position, dt, velocity)
}
// Suppose that we need to flow for 43 miliseconds, and dt is 10 miliseconds.
// Then we do micro-flow 4 times totalling 40 miliseconds. We don't do micro-flow for 3 seconds
// - that could cause problems with integration.
// Instead we remember this leftover time, so the next time we flow, we add this leftover time to the total time to flow.
let leftover_time: Duration = 0;
// Integrates the micro-flow, updates global time, and recomputes the observables.
function flow(t: Duration) {
// TODO: They way we compute leftover time is not so great. There must be a better way.
const end_time = time + t + leftover_time;
while (true) {
const next_time = time + config.dt;
if (next_time <= end_time) {
time = next_time;
microFlow(config.dt);
} else {
// assume at the start
// dt = 10 ms
//
// time = 0
// leftover_time = 0
// t = 43 ms
// then here
// next_time = 50 ms
// time = 40 ms
// end_time = 43 ms
// so actually we have 3 miliseconds leftover, i.e. we set
leftover_time = end_time - time;
break;
}
}
}
return {
state,
observables,
flow,
refreshObservables,
totalTime() { return time; },
reset,
capabilities: {},
};
}

View file

@ -0,0 +1,96 @@
import { Config, State, Observables, SimpleKinematicsSystem } from "./simple_kinematics";
import { Duration } from "../helpers";
import { createSignal, onMount } from "solid-js";
import { ObservableEntry, ObservablesPanel } from "../ui_helpers";
import { Draw } from "../draw";
import { useSimulation } from "../useSimulation";
function entries(obs: Observables): ObservableEntry[] {
return [
{ type: "header", label: "General" },
{ type: "scalar", label: "Particles", value: obs.particleCount },
];
}
function render(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
system: SimpleKinematicsSystem,
) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { particleCount } = system.observables;
const { position } = system.state;
for (let i = 0; i < particleCount; i++) {
const radius = 2;
Draw.particle(ctx, position.x[i], position.y[i], radius, `hsl(30, 100%, 50%)`);
}
}
export function SimpleKinematicsView(props: { system: SimpleKinematicsSystem }) {
const [uiState, setUiState] = createSignal<Observables>(structuredClone(props.system.observables));
let canvasRef!: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
onMount(() => {
ctx = canvasRef.getContext("2d")!;
});
const { fps, isPlaying, togglePlay } = useSimulation({
step(dt: Duration) {
props.system.flow(dt);
},
render() {
props.system.refreshObservables();
render(canvasRef, ctx, props.system);
},
sync() {
setUiState(structuredClone(props.system.observables));
},
isPlaying: true,
});
return (
<div class="app-layout">
<div class="cell-0"></div>
{/* === Top Controls === */}
<div class="cell-1 controls">
<button class="btn" onClick={togglePlay} style="width: 70px">
{isPlaying() ? "Pause" : "Play"}
</button>
<button class="btn" onClick={props.system.reset} style="width: 70px">
Reset
</button>
<span class="top-stats">
FPS: {fps()}
</span>
</div>
<div class="cell-2"></div>
{/* === Reserved for future brushes === */}
<div class="cell-3 brushes-panel">
</div>
{/* === Canvas === */}
<div class="cell-4 canvas-container">
<canvas
ref={canvasRef}
width={800} height={600}
/>
</div>
{/* === Observables === */}
<div class="cell-5 side-panel">
<ObservablesPanel data={ entries(uiState()) } />
</div>
</div>
);
}

83
src/ui_helpers.tsx Normal file
View file

@ -0,0 +1,83 @@
import { formatNumber } from "./helpers";
import { For, Switch, Match } from "solid-js";
type ScalarEntry = {
type: "scalar",
label: string,
value: number,
}
type VectorEntry = {
type: "vector",
label: string,
vector: [number, number]
components: [string, string],
}
type HeaderEntry = {
type: "header",
label: string,
}
export type ObservableEntry =
| ScalarEntry
| VectorEntry
| HeaderEntry
export function ObservablesPanel(props: { data: ObservableEntry[] }) {
return (
<div
class="observables-container"
style={{
display: "grid",
// 1: Main Label | 2: Sub-label X | 3: Value X | 4: Sub-label Y | 5: Value Y
"grid-template-columns": "auto min-content 1fr min-content 1fr",
"align-items": "baseline",
}}
>
<For each={ props.data }>
{ (item: ObservableEntry) => (
<Switch>
<Match when={ item.type === "header" }>
<div
class="observables-header"
style="grid-column: 1 / -1"
>
{ item.label }
</div>
</Match>
<Match when={ item.type === "scalar" }>
<div class="observables-row scalar-row" style="display: contents">
<span class="observables-label">{ item.label }</span>
<span class="observables-value scalar-value" style="grid-column: 2 / -1">
{ formatNumber((item as ScalarEntry).value) }
</span>
</div>
</Match>
<Match when={ item.type === "vector" }>
<div class="observables-row vector-row" style="display: contents">
<span class="observables-label">{ item.label }</span>
<span class="observables-component-label">
{ (item as VectorEntry).components[0] }:
</span>
<span class="observables-value">
{ formatNumber((item as VectorEntry).vector[0]) }
</span>
<span class="observables-component-label">
{ (item as VectorEntry).components[1] }:
</span>
<span class="observables-value">
{ formatNumber((item as VectorEntry).vector[1]) }
</span>
</div>
</Match>
</Switch>
)}
</For>
</div>
);
}

0
src/usePointer.ts Normal file
View file

88
src/useSimulation.ts Normal file
View file

@ -0,0 +1,88 @@
import { createSignal, onCleanup } from "solid-js";
import { Duration } from "./helpers";
export type SimConfig = {
step(dt: Duration): void, // The physics step
render(): void, // The pure re-rendering
sync(): void, // The UI update (throttled)
isPlaying: boolean, // Should the simulation start immediately, or should it be paused?
syncEveryFrames?: number, // Default 10
panicCap?: number, // Default 300
}
const syncEveryFramesDefault: number = 10;
const panicCapDefault: number = 300;
// isPlaying == True:
// - the control of the rendering/stepping/syncing is in the hands of the simulation
// - client of the simulation has no control
// isPlaying == False:
// - the control of the rendering/stepping/syncing is in the hands of the client
//
// TODO: Perhaps you should create like two types for such objects, and think of them linearly...
// - one can become the other... but both can't really exist at once... Where are Linear Types when I need them?
export function useSimulation(config: SimConfig) {
const [fps, setFps] = createSignal(0);
const [isPlaying, setIsPlaying] = createSignal(config.isPlaying);
const { step, render, sync } = config;
const syncEveryFrames = config.syncEveryFrames ?? syncEveryFramesDefault;
const panicCap = config.panicCap ?? panicCapDefault;
let frameId: number = 0;
let lastTime = performance.now();
let frameCounter = 0;
function loop(now: number) {
frameCounter += 1;
let dt = now - lastTime;
lastTime = now;
if (dt > panicCap) dt = panicCap;
step(dt);
render();
if (frameCounter % syncEveryFrames === 0) {
setFps(Math.round(1000 / dt));
sync();
}
frameId = requestAnimationFrame(loop);
}
function togglePlay() {
if (isPlaying()) {
cancelAnimationFrame(frameId);
setIsPlaying(false);
} else {
// Reset lastTime so we don't calculate a massive time jump from while it was paused
lastTime = performance.now();
frameId = requestAnimationFrame(loop);
setIsPlaying(true);
}
}
function requestSyncedRender() {
if (!isPlaying()) {
render();
sync();
}
}
function requestSimpleRender() {
if (!isPlaying()) {
render();
}
}
if (isPlaying()) {
frameId = requestAnimationFrame(loop);
}
onCleanup(() => {
cancelAnimationFrame(frameId)
});
return { fps, isPlaying, togglePlay, requestSyncedRender, requestSimpleRender };
}

25
tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Strictness */
"strict": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

BIN
units_and_types_0.kra Executable file

Binary file not shown.

BIN
units_and_types_0.kra~ Executable file

Binary file not shown.

17
vite.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
export default defineConfig({
plugins: [solidPlugin()],
build: {
target: 'esnext',
minify: 'esbuild',
},
server: {
// Required for SharedArrayBuffer or multi-threading
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});