diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..8ab4689 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:web"], + "port": 5173 + } + ] +} diff --git a/demo/IR Distance Example/IR Distance Sensor/IR_Distance_Sensor.ino b/demo/IR Distance Example/IR Distance Sensor/IR_Distance_Sensor.ino new file mode 100644 index 0000000..03061f5 --- /dev/null +++ b/demo/IR Distance Example/IR Distance Sensor/IR_Distance_Sensor.ino @@ -0,0 +1,34 @@ +/* + IR Distance Sensor + + Created with tinyStudio + Date: 6/24/2026 + + Description: + A basic sketch template. Customize this code for your project needs. +*/ + +// Pin definitions +const int LED_PIN = 13; // Built-in LED pin + +void setup() { + // Initialize serial communication + Serial.begin(9600); + + // Initialize digital pin LED_PIN as an output + pinMode(LED_PIN, OUTPUT); + + Serial.println("IR Distance Sensor - Setup complete!"); +} + +void loop() { + // Turn the LED on + digitalWrite(LED_PIN, HIGH); + Serial.println("LED ON"); + delay(1000); // Wait for a second + + // Turn the LED off + digitalWrite(LED_PIN, LOW); + Serial.println("LED OFF"); + delay(1000); // Wait for a second +} diff --git a/demo/IR Distance Example/IR Distance Sensor/README.md b/demo/IR Distance Example/IR Distance Sensor/README.md new file mode 100644 index 0000000..5673a50 --- /dev/null +++ b/demo/IR Distance Example/IR Distance Sensor/README.md @@ -0,0 +1,19 @@ +# IR Distance Sensor + +A tinyCore project created with tinyStudio. + +## Getting Started + +1. Open this project in tinyStudio +2. Connect your tinyCore +3. Upload the sketch to your board + +## Hardware Requirements + +- tinyCore (or compatible board) +- USB cable +- Additional components as needed + +## Circuit Diagram + +Add your circuit diagram and connections here. diff --git a/demo/IR Distance Example/IR Distance Sensor/diagram.json b/demo/IR Distance Example/IR Distance Sensor/diagram.json new file mode 100644 index 0000000..8766c34 --- /dev/null +++ b/demo/IR Distance Example/IR Distance Sensor/diagram.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "editor": "tinystudio", + "parts": [], + "connections": [] +} \ No newline at end of file diff --git a/demo/IR Distance Example/IR Distance Sensor/visual.js b/demo/IR Distance Example/IR Distance Sensor/visual.js new file mode 100644 index 0000000..a170921 --- /dev/null +++ b/demo/IR Distance Example/IR Distance Sensor/visual.js @@ -0,0 +1,54 @@ +// visual.js — Serial Plotter +// Graphs the latest number printed over Serial (serialValue()) as a scrolling +// line, auto-scaling to the data. Try Serial.println(analogRead(A0)) on the +// board. Switch to Code to edit this sketch; Visual to run it. + +let data = []; +const MAX = 240; // points kept on screen + +function setup() { + createCanvas(480, 280); + textFont('monospace'); +} + +function draw() { + background(7, 11, 34); + + // pull the most recent serial value each frame + data.push(serialValue()); + if (data.length > MAX) data.shift(); + + // auto-scale to the data range (with a little headroom) + let lo = Math.min(...data, 0); + let hi = Math.max(...data, 1); + if (hi === lo) hi = lo + 1; + + // grid + stroke(26, 31, 77); + strokeWeight(1); + for (let i = 0; i <= 4; i++) { + let y = map(i, 0, 4, 20, height - 24); + line(40, y, width - 12, y); + } + + // plotted line + noFill(); + stroke(0, 240, 255); + strokeWeight(2); + beginShape(); + for (let i = 0; i < data.length; i++) { + let x = map(i, 0, MAX - 1, 40, width - 12); + let y = map(data[i], lo, hi, height - 24, 20); + vertex(x, y); + } + endShape(); + + // readouts + noStroke(); + fill(235, 238, 255); + textSize(12); + text('value: ' + serialValue().toFixed(2), 44, 16); + fill(120, 130, 170); + text(hi.toFixed(0), 8, 24); + text(lo.toFixed(0), 8, height - 24); +} diff --git a/demo/Joystick Example/README.md b/demo/Joystick Example/README.md index df6b4a3..23e51de 100644 --- a/demo/Joystick Example/README.md +++ b/demo/Joystick Example/README.md @@ -1,16 +1,27 @@ -# Qwiic Joystick Example +# 🕹️ Qwiic Joystick Asteroids -Stream the SparkFun Qwiic Joystick's X/Y position (and button) over serial, then watch -it move on a live grid in the **Visual** tab. +Stream the SparkFun Qwiic Joystick's X/Y position (and button) over serial, then **play a +full game of Asteroids** with it in the **Visual** tab. Built with **tinyStudio** for the **tinyCore** ESP32-S3 board by MR.INDUSTRIES. +--- + ## What it does - [x] Talks to the Qwiic Joystick over I²C (default address `0x20`) - [x] Reads horizontal + vertical as 10-bit values (`0..1023`, center `~512`) -- [x] Streams `x,y,button` over serial at `115200` baud -- [x] `visual.js` plots the position on an X/Y grid to match +- [x] Streams `x,y,button` over serial as a simple CSV line +- [x] `visual.js` parses that stream and lets you **fly a ship, rotate, thrust, and shoot rocks** + +```mermaid +flowchart LR + JOY[Qwiic Joystick] -->|I²C SDA/SCL| MCU[tinyCore] + MCU -->|"Serial.println x,y,button"| MON[Serial Stream] + MON --> VIS[visual.js · Asteroids] +``` + +--- ## Hardware @@ -20,27 +31,115 @@ soldering, no breadboard. (Qwiic carries `3V3 · GND · SDA · SCL`.) Install the **SparkFun Qwiic Joystick Arduino Library** from the Library Manager. -## Signal flow - -```mermaid -flowchart LR - JOY[Qwiic Joystick] -->|I²C SDA/SCL| MCU[tinyCore] - MCU -->|Serial.println x,y,button| MON[Serial Monitor] - MON --> VIS[visual.js grid] -``` +--- ## Serial format -One line per sample, ~20 Hz: +One CSV line per sample, ~20 Hz: ``` 512,498,0 ``` -`x , y , button` — `button` is `1` while pressed. +`x , y , button` — where `x`/`y` are `0..1023` (center `~512`) and `button` is `1` while pressed. + +--- + +## 📟 `qwiic_joystick.ino` — the firmware + +This is the code that runs on the tinyCore. Its only job is to read the joystick and print +a clean CSV line that the visualizer can parse. + +### Walkthrough + +| Stage | What happens | +|-------|--------------| +| **Includes** | `Wire.h` for I²C and `SparkFun_Qwiic_Joystick_Arduino_Library.h` for the driver. A global `JOYSTICK joystick;` object handles all register reads. | +| **`setup()`** | Starts serial at `9600` baud, then brings up the I²C bus with `Wire.begin(3, 4)` — pins **3 (SDA)** and **4 (SCL)** are the tinyCore's Qwiic bus. | +| **Self-check** | `joystick.begin()` returns `false` if the device isn't found. If so, it prints a hint to check the Qwiic cable and halts in a `while (1)` loop so you're not left guessing. | +| **`loop()`** | Reads the three values and prints them as `x,y,button`, then `delay(50)` paces the stream to roughly 20 Hz. | + +### Key lines + +```cpp +int x = joystick.getHorizontal(); // 0..1023, center ~512 +int y = joystick.getVertical(); // 0..1023, center ~512 +int b = (joystick.getButton() == 0) ? 1 : 0; // active-low → 1 = pressed +``` + +> **Note:** the joystick button is **active-low** (the library returns `0` when pressed), +> so the sketch inverts it to the more intuitive `1 = pressed` before sending. + +--- + +## 🎮 `visual.js` — the Asteroids game + +A p5.js sketch that turns the joystick stream into a playable game. It reads the **same CSV +format** the firmware emits, so the firmware and visualizer stay perfectly in sync. + +### Controls + +| Action | Joystick | Keyboard fallback* | +|--------|----------|--------------------| +| Rotate | Tilt **left / right** | ← / → arrows | +| Thrust | Push **up** | ↑ arrow | +| Fire | Press the **button** | Spacebar | +| Restart (after game over) | Press the **button** | Spacebar | + +> \*If the tinyStudio serial harness isn't present (e.g. a plain browser), `visual.js` +> automatically falls back to the keyboard so you can test the game before wiring anything up. + +### How the input is read + +```js +// parse "x,y,button" -> {x, y, b} (same regex the firmware feeds) +function parseLine(s) { ... } + +// pull the latest line from the harness, or null when not connected +function readJoystick() { + if (typeof serialRead !== 'function') return null; + return parseLine(serialRead()); +} +``` + +Each frame `handleInput()` grabs the newest joystick reading. A **deadzone** (`±90` around +center) ignores small wobble so the ship doesn't drift at rest, and a **rising-edge** check +on the button (`btn === 1 && lastBtn === 0`) means one press fires exactly one shot. + +### Game architecture + +| Piece | Role | +|-------|------| +| **`ship`** | Position, velocity, and angle. X tilt rotates it; pushing up adds thrust along its heading. Movement uses friction + a max-speed clamp, and the ship **wraps** around screen edges. | +| **`bullets`** | Spawned from the ship's nose, inherit ship velocity, and expire after `BULLET_LIFE` frames. | +| **`rocks`** | Lumpy procedurally-generated asteroids in three sizes. Large rocks split into two smaller ones when shot; small rocks just vanish. | +| **`checkCollisions()`** | Bullet-vs-rock (score + split) and ship-vs-rock (lose a life). Smaller rocks are worth more points. | +| **State** | `'play'` / `'over'`, with `lives`, `score`, a fire cooldown, and brief **invulnerability** (the blinking ship) after respawning. | +| **`nextWave()`** | Clearing all rocks spawns a new, slightly larger wave that scales with your score. | + +### Tuning knobs + +All the feel of the game lives in a block of constants near the top, so it's easy to tweak: + +```js +const DEADZONE = 90; // ignore small wobble around center +const TURN_SPEED = 0.07; // radians per frame at full tilt +const THRUST_ACCEL = 0.12; +const FRICTION = 0.99; +const MAX_SPEED = 7; +const BULLET_SPEED = 7; +const FIRE_COOLDOWN = 10; // frames between shots +const START_LIVES = 3; +``` + +The palette constants (`C_BG`, `C_SHIP`, `C_ROCK`, `C_BULLET`) keep the firmware demo and the +game looking consistent. + +--- -## Build +## Build & play -1. **Verify** to compile. +1. **Verify** to compile the sketch. 2. **Upload** to your connected tinyCore. -3. Open the **Visual** tab to watch the joystick move on the grid. +3. Open the **Visual** tab — tilt to steer, push up to thrust, and press the button to shoot. +4. Clear every rock to advance to the next wave. Good luck! 🚀 diff --git a/demo/Joystick Example/asteroids.js b/demo/Joystick Example/asteroids.js new file mode 100644 index 0000000..18a0ba2 --- /dev/null +++ b/demo/Joystick Example/asteroids.js @@ -0,0 +1,343 @@ +// asteroids.js — Qwiic Joystick Asteroids +// Classic Asteroids driven by the SparkFun Qwiic Joystick. +// Reads the same CSV stream as the visualizer: x,y,button (x,y in 0..1023) +// Tilt LEFT / RIGHT to rotate, push UP to thrust, press the button to fire. +// +// serialRead() -> latest raw line, e.g. "512,498,0" +// serialLines() -> recent raw lines (unused here, kept for parity) +// serialEvent() -> fires per new line (kept for parity) +// +// Built for tinyStudio's harness. If the serial functions aren't present +// (e.g. a plain browser / the p5 web editor), it falls back to the keyboard +// (arrows to steer/thrust, space to fire) so you can test before wiring up. + +const RANGE = 1023; // 10-bit ADC full scale +const CENTER = RANGE / 2; // joystick resting value +const DEADZONE = 90; // ignore small wobble around center +const START_LIVES = 3; + +// ---- palette (matches the visualizer) ---- +const C_BG = [10, 15, 45]; +const C_SHIP = [255, 255, 255]; +const C_ROCK = [150, 162, 196]; +const C_BULLET = [255, 215, 0]; // gold +const C_TEXT = [150, 162, 196]; + +// ---- tuning ---- +const TURN_SPEED = 0.07; // radians per frame at full tilt +const THRUST_ACCEL = 0.12; +const FRICTION = 0.99; +const MAX_SPEED = 7; +const BULLET_SPEED = 7; +const BULLET_LIFE = 60; // frames +const FIRE_COOLDOWN = 10; // frames between shots + +let ship, bullets, rocks; +let score = 0, lives = START_LIVES; +let state = 'play'; // 'play' | 'over' +let joyX = CENTER, joyY = CENTER, lastBtn = 0; +let fireTimer = 0, invuln = 0; + +function setup() { + createCanvas(640, 440); + textFont('monospace'); + resetGame(); +} + +function resetGame() { + score = 0; + lives = START_LIVES; + state = 'play'; + bullets = []; + spawnShip(); + rocks = []; + for (let i = 0; i < 4; i++) rocks.push(makeRock()); +} + +function spawnShip() { + ship = { x: width / 2, y: height / 2, vx: 0, vy: 0, angle: -PI / 2 }; + invuln = 90; +} + +// parse "x,y,button" -> {x,y,b} or null (same regex as the visualizer) +function parseLine(s) { + let m = String(s).match(/(-?\d+)\s*,\s*(-?\d+)(?:\s*,\s*(\d+))?/); + if (!m) return null; + return { x: int(m[1]), y: int(m[2]), b: m[3] ? int(m[3]) : 0 }; +} + +// read the joystick if the harness is present, else null +function readJoystick() { + if (typeof serialRead !== 'function') return null; + return parseLine(serialRead()); +} + +function makeRock(size, x, y) { + size = size || 3; // 3 = large, 2 = medium, 1 = small + // spawn at a random edge if no position given + if (x === undefined) { + if (random() < 0.5) { x = random() < 0.5 ? 0 : width; y = random(height); } + else { x = random(width); y = random() < 0.5 ? 0 : height; } + } + let r = size * 16; + let speed = map(size, 3, 1, 0.7, 2.2); + let a = random(TWO_PI); + // give each rock a lumpy outline + let verts = []; + let n = floor(random(8, 12)); + for (let i = 0; i < n; i++) { + verts.push(random(0.75, 1.15)); + } + return { + x, y, size, r, + vx: cos(a) * speed, + vy: sin(a) * speed, + rot: random(-0.03, 0.03), + spin: 0, + verts + }; +} + +function draw() { + background(C_BG); + handleInput(); + if (state === 'play') { + updateShip(); + updateBullets(); + updateRocks(); + checkCollisions(); + if (rocks.length === 0) nextWave(); + } + drawRocks(); + drawBullets(); + drawShip(); + drawHud(); +} + +function handleInput() { + let p = readJoystick(); + let btn = 0; + + if (p) { + joyX = p.x; + joyY = p.y; + btn = p.b; + } else if (typeof serialRead !== 'function') { + // no harness: keyboard fallback + joyX = CENTER + (keyIsDown(RIGHT_ARROW) ? 400 : 0) - (keyIsDown(LEFT_ARROW) ? 400 : 0); + joyY = CENTER + (keyIsDown(UP_ARROW) ? 400 : 0); + btn = keyIsDown(32) ? 1 : 0; // space + } + + // rising-edge button: fire, or restart when game over + if (btn === 1 && lastBtn === 0) onButton(); + lastBtn = btn; +} + +function onButton() { + if (state === 'over') { + resetGame(); + } else if (fireTimer <= 0) { + fire(); + } +} + +function fire() { + bullets.push({ + x: ship.x + cos(ship.angle) * 14, + y: ship.y + sin(ship.angle) * 14, + vx: cos(ship.angle) * BULLET_SPEED + ship.vx, + vy: sin(ship.angle) * BULLET_SPEED + ship.vy, + life: BULLET_LIFE + }); + fireTimer = FIRE_COOLDOWN; +} + +function updateShip() { + if (fireTimer > 0) fireTimer--; + if (invuln > 0) invuln--; + + // X axis -> rotate (deadzone keeps it steady at rest) + let dx = joyX - CENTER; + if (abs(dx) > DEADZONE) ship.angle += (dx / CENTER) * TURN_SPEED; + + // Y axis: push UP to thrust (joystick reads larger when pushed up) + let dy = joyY - CENTER; + if (dy > DEADZONE) { + let t = (dy / CENTER) * THRUST_ACCEL; + ship.vx += cos(ship.angle) * t; + ship.vy += sin(ship.angle) * t; + } + + // clamp speed + friction + let sp = mag(ship.vx, ship.vy); + if (sp > MAX_SPEED) { ship.vx *= MAX_SPEED / sp; ship.vy *= MAX_SPEED / sp; } + ship.vx *= FRICTION; + ship.vy *= FRICTION; + + ship.x = wrap(ship.x + ship.vx, width); + ship.y = wrap(ship.y + ship.vy, height); +} + +function updateBullets() { + for (let b of bullets) { + b.x = wrap(b.x + b.vx, width); + b.y = wrap(b.y + b.vy, height); + b.life--; + } + bullets = bullets.filter(b => b.life > 0); +} + +function updateRocks() { + for (let r of rocks) { + r.x = wrap(r.x + r.vx, width); + r.y = wrap(r.y + r.vy, height); + r.spin += r.rot; + } +} + +function checkCollisions() { + // bullets vs rocks + for (let i = rocks.length - 1; i >= 0; i--) { + let rock = rocks[i]; + for (let j = bullets.length - 1; j >= 0; j--) { + let b = bullets[j]; + if (dist(b.x, b.y, rock.x, rock.y) < rock.r) { + bullets.splice(j, 1); + rocks.splice(i, 1); + score += (4 - rock.size) * 10; // small rocks worth more + splitRock(rock); + break; + } + } + } + + // ship vs rocks (ignored while invulnerable) + if (invuln <= 0) { + for (let rock of rocks) { + if (dist(ship.x, ship.y, rock.x, rock.y) < rock.r + 8) { + loseLife(); + break; + } + } + } +} + +function splitRock(rock) { + if (rock.size <= 1) return; + for (let k = 0; k < 2; k++) { + rocks.push(makeRock(rock.size - 1, rock.x, rock.y)); + } +} + +function loseLife() { + lives--; + if (lives <= 0) { + state = 'over'; + } else { + spawnShip(); + } +} + +function nextWave() { + let count = 4 + floor(score / 200); + for (let i = 0; i < count; i++) rocks.push(makeRock()); +} + +function wrap(v, max) { + if (v < 0) return v + max; + if (v > max) return v - max; + return v; +} + +// ---------- drawing ---------- + +function drawShip() { + if (state !== 'play') return; + if (invuln > 0 && frameCount % 10 < 5) return; // blink while invulnerable + + push(); + translate(ship.x, ship.y); + rotate(ship.angle); + stroke(C_SHIP); + strokeWeight(2); + noFill(); + // triangle pointing along +x (angle 0) + beginShape(); + vertex(14, 0); + vertex(-10, -9); + vertex(-5, 0); + vertex(-10, 9); + endShape(CLOSE); + + // thrust flame when pushing up + if (joyY - CENTER > DEADZONE && frameCount % 6 < 3) { + stroke(C_BULLET); + line(-5, 0, -16, 0); + } + pop(); +} + +function drawBullets() { + noStroke(); + fill(C_BULLET); + for (let b of bullets) circle(b.x, b.y, 4); +} + +function drawRocks() { + stroke(C_ROCK); + strokeWeight(2); + noFill(); + for (let r of rocks) { + push(); + translate(r.x, r.y); + rotate(r.spin); + beginShape(); + let n = r.verts.length; + for (let i = 0; i < n; i++) { + let a = (i / n) * TWO_PI; + let rad = r.r * r.verts[i]; + vertex(cos(a) * rad, sin(a) * rad); + } + endShape(CLOSE); + pop(); + } +} + +function drawHud() { + noStroke(); + textAlign(LEFT, TOP); + textSize(20); + fill(C_SHIP); + text(score, 14, 12); + + // lives as little ships + for (let i = 0; i < lives; i++) { + push(); + translate(width - 20 - i * 22, 22); + rotate(-PI / 2); + stroke(C_SHIP); + strokeWeight(2); + noFill(); + beginShape(); + vertex(9, 0); + vertex(-6, -6); + vertex(-6, 6); + endShape(CLOSE); + pop(); + } + + if (state === 'over') { + noStroke(); + textAlign(CENTER, CENTER); + textSize(28); + fill(C_SHIP); + text('GAME OVER', width / 2, height / 2 - 16); + textSize(14); + fill(C_BULLET); + text('PRESS BUTTON TO RESTART', width / 2, height / 2 + 16); + } +} + +// fires per new serial line (kept for parity with the harness) +function serialEvent(line) {} \ No newline at end of file diff --git a/demo/Joystick Example/pong.js b/demo/Joystick Example/pong.js new file mode 100644 index 0000000..e5c2922 --- /dev/null +++ b/demo/Joystick Example/pong.js @@ -0,0 +1,233 @@ +// pong.js — Qwiic Joystick Pong +// Single-player Pong driven by the SparkFun Qwiic Joystick. +// Reads the same CSV stream as the visualizer: x,y,button (x,y in 0..1023) +// Push the stick UP / DOWN to move the left paddle. Press the button to serve. +// +// serialRead() -> latest raw line, e.g. "512,498,0" +// serialLines() -> recent raw lines (unused here, kept for parity) +// serialEvent() -> fires per new line (kept for parity) +// +// Built for tinyStudio's harness. If the serial functions aren't present +// (e.g. a plain browser / the p5 web editor), it falls back to the mouse so +// you can test the game logic before wiring up hardware. + +const RANGE = 1023; // 10-bit ADC full scale +const WIN_SCORE = 7; + +// ---- palette (matches the visualizer) ---- +const C_BG = [10, 15, 45]; +const C_NET = [26, 34, 70]; +const C_PLAYER = [255, 255, 255]; // cyan +const C_AI = [255, 255, 255]; // pink +const C_BALL = [255, 215, 0]; // gold +const C_TEXT = [150, 162, 196]; + +// ---- geometry ---- +const PADDLE_W = 12; +const PADDLE_H = 84; +const PADDLE_INSET = 26; // distance of the paddle face from the wall +const BALL_R = 9; +const AI_MAX_SPEED = 5.2; // lower = easier to beat + +let player, ai, ball; +let scoreP = 0, scoreA = 0; +let state = 'serve'; // 'serve' | 'play' | 'over' +let winner = ''; +let joyY = 512, lastBtn = 0; + +function setup() { + createCanvas(640, 440); + textFont('monospace'); + player = { x: PADDLE_INSET, y: height / 2 }; + ai = { x: width - PADDLE_INSET, y: height / 2 }; + resetBall(1); +} + +// parse "x,y,button" -> {x,y,b} or null (same regex as the visualizer) +function parseLine(s) { + let m = String(s).match(/(-?\d+)\s*,\s*(-?\d+)(?:\s*,\s*(\d+))?/); + if (!m) return null; + return { x: int(m[1]), y: int(m[2]), b: m[3] ? int(m[3]) : 0 }; +} + +// read the joystick if the harness is present, else null +function readJoystick() { + if (typeof serialRead !== 'function') return null; + return parseLine(serialRead()); +} + +function resetBall(dir) { + // dir: +1 serves right (toward CPU), -1 serves left (toward you) + ball = { + x: width / 2, + y: height / 2, + vx: 4.2 * dir, + vy: random(-2.2, 2.2), + speed: 4.6 + }; +} + +function draw() { + background(C_BG); + handleInput(); + if (state === 'play') updateBall(); + drawNet(); + drawPaddles(); + drawBall(); + drawHud(); +} + +function handleInput() { + let p = readJoystick(); + let btn = 0; + + if (p) { + joyY = p.y; // fresh joystick sample + btn = p.b; + } else if (typeof serialRead !== 'function') { + // no harness: mouse Y drives the paddle, click serves + joyY = map(mouseY, height, 0, 0, RANGE, true); + btn = mouseIsPressed ? 1 : 0; + } + // (harness present but no parseable line yet -> keep last joyY, btn stays 0) + + // stick UP -> paddle UP, matching the visualizer's inverted Y + player.y = map(joyY, 0, RANGE, height - PADDLE_H / 2, PADDLE_H / 2, true); + + // rising-edge button: serve or restart + if (btn === 1 && lastBtn === 0) onButton(); + lastBtn = btn; +} + +function onButton() { + if (state === 'serve') { + state = 'play'; + } else if (state === 'over') { + scoreP = 0; + scoreA = 0; + winner = ''; + state = 'serve'; + resetBall(random() < 0.5 ? 1 : -1); + } +} + +function updateBall() { + ball.x += ball.vx; + ball.y += ball.vy; + + // top / bottom walls + if (ball.y - BALL_R < 0) { ball.y = BALL_R; ball.vy *= -1; } + if (ball.y + BALL_R > height) { ball.y = height - BALL_R; ball.vy *= -1; } + + bouncePaddle(player, +1); // left paddle -> ball leaves rightward + bouncePaddle(ai, -1); // right paddle -> ball leaves leftward + + // CPU: chase only while the ball approaches, else drift back to center + let targetY = ball.vx > 0 ? ball.y : height / 2; + ai.y += constrain(targetY - ai.y, -AI_MAX_SPEED, AI_MAX_SPEED); + ai.y = constrain(ai.y, PADDLE_H / 2, height - PADDLE_H / 2); + + // scoring + if (ball.x + BALL_R < 0) { scoreA++; afterPoint(-1); } // CPU scored + if (ball.x - BALL_R > width) { scoreP++; afterPoint(+1); } // you scored +} + +function bouncePaddle(pad, dir) { + // dir: +1 = paddle on the left, -1 = paddle on the right + let halfW = PADDLE_W / 2; + let halfH = PADDLE_H / 2; + + // only register a hit when the ball is heading toward this paddle + if (dir > 0 && ball.vx >= 0) return; + if (dir < 0 && ball.vx <= 0) return; + + let withinX = abs(ball.x - pad.x) <= halfW + BALL_R; + let withinY = ball.y >= pad.y - halfH - BALL_R && + ball.y <= pad.y + halfH + BALL_R; + if (!(withinX && withinY)) return; + + // reflect with angle based on where it struck the paddle, speed up a touch + ball.speed = min(ball.speed + 0.35, 11); + let offset = constrain((ball.y - pad.y) / halfH, -1, 1); // -1 (top) .. 1 (bottom) + let angle = offset * (PI / 3.2); // up to ~56° + ball.vx = ball.speed * cos(angle) * dir; + ball.vy = ball.speed * sin(angle); + + // nudge clear so it can't stick inside the paddle + ball.x = pad.x + dir * (halfW + BALL_R + 0.5); +} + +function afterPoint(scorer) { + // scorer: +1 you scored, -1 CPU scored + if (scoreP >= WIN_SCORE || scoreA >= WIN_SCORE) { + state = 'over'; + winner = scoreP > scoreA ? 'PLAYER' : 'CPU'; + return; + } + state = 'serve'; + resetBall(scorer); // serve toward whoever just conceded the point +} + +function drawNet() { + stroke(C_NET); + strokeWeight(3); + for (let y = 12; y < height; y += 26) line(width / 2, y, width / 2, y + 14); +} + +function drawPaddles() { + noStroke(); + rectMode(CENTER); + // player (cyan) with glow + fill(C_PLAYER[0], C_PLAYER[1], C_PLAYER[2], 60); + rect(player.x, player.y, PADDLE_W + 8, PADDLE_H + 8, 6); + fill(C_PLAYER); + rect(player.x, player.y, PADDLE_W, PADDLE_H, 4); + // CPU (pink) with glow + fill(C_AI[0], C_AI[1], C_AI[2], 60); + rect(ai.x, ai.y, PADDLE_W + 8, PADDLE_H + 8, 6); + fill(C_AI); + rect(ai.x, ai.y, PADDLE_W, PADDLE_H, 4); + rectMode(CORNER); +} + +function drawBall() { + noStroke(); + fill(C_BALL[0], C_BALL[1], C_BALL[2], 60); + circle(ball.x, ball.y, BALL_R * 2 + 12); + fill(C_BALL); + circle(ball.x, ball.y, BALL_R * 2); +} + +function drawHud() { + noStroke(); + textAlign(CENTER, TOP); + textSize(34); + fill(C_PLAYER); + text(scoreP, width / 2 - 60, 12); + fill(C_AI); + text(scoreA, width / 2 + 60, 12); + + textSize(11); + fill(C_TEXT); + textAlign(LEFT, TOP); + text('YOU', 14, 16); + textAlign(RIGHT, TOP); + text('CPU', width - 14, 16); + + if (state === 'serve') { + banner('PRESS BUTTON TO SERVE'); + } else if (state === 'over') { + banner((winner === 'PLAYER' ? 'YOU WIN!' : 'CPU WINS') + ' PRESS TO RESTART'); + } +} + +function banner(msg) { + noStroke(); + textAlign(CENTER, CENTER); + textSize(16); + fill(C_BALL); + text(msg, width / 2, height - 28); +} + +// fires per new serial line (kept for parity with the harness) +function serialEvent(line) {} diff --git a/demo/Joystick Example/visual.js b/demo/Joystick Example/visual.js index e390153..19e2216 100644 --- a/demo/Joystick Example/visual.js +++ b/demo/Joystick Example/visual.js @@ -1,343 +1,102 @@ -// asteroids.js — Qwiic Joystick Asteroids -// Classic Asteroids driven by the SparkFun Qwiic Joystick. -// Reads the same CSV stream as the visualizer: x,y,button (x,y in 0..1023) -// Tilt LEFT / RIGHT to rotate, push UP to thrust, press the button to fire. +// visual.js — Qwiic Joystick X/Y grid visualizer +// Plots the joystick's live position on a grid to match what the sketch streams. +// The sketch prints one CSV line per sample: x,y,button (x,y in 0..1023) // // serialRead() -> latest raw line, e.g. "512,498,0" -// serialLines() -> recent raw lines (unused here, kept for parity) -// serialEvent() -> fires per new line (kept for parity) -// -// Built for tinyStudio's harness. If the serial functions aren't present -// (e.g. a plain browser / the p5 web editor), it falls back to the keyboard -// (arrows to steer/thrust, space to fire) so you can test before wiring up. - -const RANGE = 1023; // 10-bit ADC full scale -const CENTER = RANGE / 2; // joystick resting value -const DEADZONE = 90; // ignore small wobble around center -const START_LIVES = 3; - -// ---- palette (matches the visualizer) ---- -const C_BG = [10, 15, 45]; -const C_SHIP = [255, 255, 255]; -const C_ROCK = [150, 162, 196]; -const C_BULLET = [255, 215, 0]; // gold -const C_TEXT = [150, 162, 196]; +// serialLines() -> recent raw lines (used to draw the trail) -// ---- tuning ---- -const TURN_SPEED = 0.07; // radians per frame at full tilt -const THRUST_ACCEL = 0.12; -const FRICTION = 0.99; -const MAX_SPEED = 7; -const BULLET_SPEED = 7; -const BULLET_LIFE = 60; // frames -const FIRE_COOLDOWN = 10; // frames between shots - -let ship, bullets, rocks; -let score = 0, lives = START_LIVES; -let state = 'play'; // 'play' | 'over' -let joyX = CENTER, joyY = CENTER, lastBtn = 0; -let fireTimer = 0, invuln = 0; +const RANGE = 1023; // 10-bit ADC full scale +const PAD = 46; // grid margin +let px = 512, py = 512; // last good position (rest = centered) +let pressed = false; function setup() { - createCanvas(640, 440); + createCanvas(440, 440); textFont('monospace'); - resetGame(); } -function resetGame() { - score = 0; - lives = START_LIVES; - state = 'play'; - bullets = []; - spawnShip(); - rocks = []; - for (let i = 0; i < 4; i++) rocks.push(makeRock()); -} - -function spawnShip() { - ship = { x: width / 2, y: height / 2, vx: 0, vy: 0, angle: -PI / 2 }; - invuln = 90; -} - -// parse "x,y,button" -> {x,y,b} or null (same regex as the visualizer) +// parse "x,y,button" -> {x,y,b} or null if the line has no numbers function parseLine(s) { let m = String(s).match(/(-?\d+)\s*,\s*(-?\d+)(?:\s*,\s*(\d+))?/); if (!m) return null; return { x: int(m[1]), y: int(m[2]), b: m[3] ? int(m[3]) : 0 }; } -// read the joystick if the harness is present, else null -function readJoystick() { - if (typeof serialRead !== 'function') return null; - return parseLine(serialRead()); -} - -function makeRock(size, x, y) { - size = size || 3; // 3 = large, 2 = medium, 1 = small - // spawn at a random edge if no position given - if (x === undefined) { - if (random() < 0.5) { x = random() < 0.5 ? 0 : width; y = random(height); } - else { x = random(width); y = random() < 0.5 ? 0 : height; } - } - let r = size * 16; - let speed = map(size, 3, 1, 0.7, 2.2); - let a = random(TWO_PI); - // give each rock a lumpy outline - let verts = []; - let n = floor(random(8, 12)); - for (let i = 0; i < n; i++) { - verts.push(random(0.75, 1.15)); - } - return { - x, y, size, r, - vx: cos(a) * speed, - vy: sin(a) * speed, - rot: random(-0.03, 0.03), - spin: 0, - verts - }; -} +// joystick -> screen. Physical stick grows LEFT in X and UP in Y +// (matching the SparkFun hookup directions), so X is inverted. +function sx(x) { return map(x, RANGE, 0, PAD, width - PAD); } +function sy(y) { return map(y, 0, RANGE, height - PAD, PAD); } function draw() { - background(C_BG); - handleInput(); - if (state === 'play') { - updateShip(); - updateBullets(); - updateRocks(); - checkCollisions(); - if (rocks.length === 0) nextWave(); - } - drawRocks(); - drawBullets(); - drawShip(); - drawHud(); -} - -function handleInput() { - let p = readJoystick(); - let btn = 0; - - if (p) { - joyX = p.x; - joyY = p.y; - btn = p.b; - } else if (typeof serialRead !== 'function') { - // no harness: keyboard fallback - joyX = CENTER + (keyIsDown(RIGHT_ARROW) ? 400 : 0) - (keyIsDown(LEFT_ARROW) ? 400 : 0); - joyY = CENTER + (keyIsDown(UP_ARROW) ? 400 : 0); - btn = keyIsDown(32) ? 1 : 0; // space - } - - // rising-edge button: fire, or restart when game over - if (btn === 1 && lastBtn === 0) onButton(); - lastBtn = btn; -} - -function onButton() { - if (state === 'over') { - resetGame(); - } else if (fireTimer <= 0) { - fire(); - } -} - -function fire() { - bullets.push({ - x: ship.x + cos(ship.angle) * 14, - y: ship.y + sin(ship.angle) * 14, - vx: cos(ship.angle) * BULLET_SPEED + ship.vx, - vy: sin(ship.angle) * BULLET_SPEED + ship.vy, - life: BULLET_LIFE - }); - fireTimer = FIRE_COOLDOWN; -} - -function updateShip() { - if (fireTimer > 0) fireTimer--; - if (invuln > 0) invuln--; - - // X axis -> rotate (deadzone keeps it steady at rest) - let dx = joyX - CENTER; - if (abs(dx) > DEADZONE) ship.angle += (dx / CENTER) * TURN_SPEED; - - // Y axis: push UP to thrust (joystick reads larger when pushed up) - let dy = joyY - CENTER; - if (dy > DEADZONE) { - let t = (dy / CENTER) * THRUST_ACCEL; - ship.vx += cos(ship.angle) * t; - ship.vy += sin(ship.angle) * t; - } - - // clamp speed + friction - let sp = mag(ship.vx, ship.vy); - if (sp > MAX_SPEED) { ship.vx *= MAX_SPEED / sp; ship.vy *= MAX_SPEED / sp; } - ship.vx *= FRICTION; - ship.vy *= FRICTION; - - ship.x = wrap(ship.x + ship.vx, width); - ship.y = wrap(ship.y + ship.vy, height); -} - -function updateBullets() { - for (let b of bullets) { - b.x = wrap(b.x + b.vx, width); - b.y = wrap(b.y + b.vy, height); - b.life--; - } - bullets = bullets.filter(b => b.life > 0); -} - -function updateRocks() { - for (let r of rocks) { - r.x = wrap(r.x + r.vx, width); - r.y = wrap(r.y + r.vy, height); - r.spin += r.rot; - } -} - -function checkCollisions() { - // bullets vs rocks - for (let i = rocks.length - 1; i >= 0; i--) { - let rock = rocks[i]; - for (let j = bullets.length - 1; j >= 0; j--) { - let b = bullets[j]; - if (dist(b.x, b.y, rock.x, rock.y) < rock.r) { - bullets.splice(j, 1); - rocks.splice(i, 1); - score += (4 - rock.size) * 10; // small rocks worth more - splitRock(rock); - break; - } - } - } - - // ship vs rocks (ignored while invulnerable) - if (invuln <= 0) { - for (let rock of rocks) { - if (dist(ship.x, ship.y, rock.x, rock.y) < rock.r + 8) { - loseLife(); - break; - } - } - } -} - -function splitRock(rock) { - if (rock.size <= 1) return; - for (let k = 0; k < 2; k++) { - rocks.push(makeRock(rock.size - 1, rock.x, rock.y)); - } -} - -function loseLife() { - lives--; - if (lives <= 0) { - state = 'over'; - } else { - spawnShip(); - } -} - -function nextWave() { - let count = 4 + floor(score / 200); - for (let i = 0; i < count; i++) rocks.push(makeRock()); -} + background(10, 15, 45); -function wrap(v, max) { - if (v < 0) return v + max; - if (v > max) return v - max; - return v; -} - -// ---------- drawing ---------- + let p = parseLine(serialRead()); + if (p) { px = p.x; py = p.y; pressed = p.b === 1; } -function drawShip() { - if (state !== 'play') return; - if (invuln > 0 && frameCount % 10 < 5) return; // blink while invulnerable + drawGrid(); - push(); - translate(ship.x, ship.y); - rotate(ship.angle); - stroke(C_SHIP); - strokeWeight(2); + // trail of recent samples + let lines = serialLines(); noFill(); - // triangle pointing along +x (angle 0) + stroke(0, 240, 255, 90); + strokeWeight(2); beginShape(); - vertex(14, 0); - vertex(-10, -9); - vertex(-5, 0); - vertex(-10, 9); - endShape(CLOSE); - - // thrust flame when pushing up - if (joyY - CENTER > DEADZONE && frameCount % 6 < 3) { - stroke(C_BULLET); - line(-5, 0, -16, 0); + for (let i = max(0, lines.length - 60); i < lines.length; i++) { + let q = parseLine(lines[i]); + if (q) vertex(sx(q.x), sy(q.y)); } - pop(); -} + endShape(); -function drawBullets() { - noStroke(); - fill(C_BULLET); - for (let b of bullets) circle(b.x, b.y, 4); -} + let gx = sx(px), gy = sy(py); -function drawRocks() { - stroke(C_ROCK); - strokeWeight(2); - noFill(); - for (let r of rocks) { - push(); - translate(r.x, r.y); - rotate(r.spin); - beginShape(); - let n = r.verts.length; - for (let i = 0; i < n; i++) { - let a = (i / n) * TWO_PI; - let rad = r.r * r.verts[i]; - vertex(cos(a) * rad, sin(a) * rad); - } - endShape(CLOSE); - pop(); - } -} + // crosshair through the live point + stroke(38, 48, 92); + strokeWeight(1); + line(PAD, gy, width - PAD, gy); + line(gx, PAD, gx, height - PAD); -function drawHud() { + // the joystick dot (glows gold while the button is pressed) + noStroke(); + let r = pressed ? 26 : 18; + fill(255, 63, 140, 60); + circle(gx, gy, r + 16); + fill(pressed ? color(255, 215, 0) : color(255, 63, 140)); + circle(gx, gy, r); + + drawReadout(gx, gy); +} + +function drawGrid() { + stroke(26, 34, 70); + strokeWeight(1); + const cells = 8; + for (let i = 0; i <= cells; i++) { + let x = map(i, 0, cells, PAD, width - PAD); + let y = map(i, 0, cells, PAD, height - PAD); + line(x, PAD, x, height - PAD); + line(PAD, y, width - PAD, y); + } + // center cross — the ~512,512 rest position + stroke(58, 70, 120); + strokeWeight(1.5); + let cx = sx(512), cy = sy(512); + line(cx - 8, cy, cx + 8, cy); + line(cx, cy - 8, cx, cy + 8); +} + +function drawReadout() { noStroke(); + textSize(12); + fill(150, 162, 196); textAlign(LEFT, TOP); - textSize(20); - fill(C_SHIP); - text(score, 14, 12); - - // lives as little ships - for (let i = 0; i < lives; i++) { - push(); - translate(width - 20 - i * 22, 22); - rotate(-PI / 2); - stroke(C_SHIP); - strokeWeight(2); - noFill(); - beginShape(); - vertex(9, 0); - vertex(-6, -6); - vertex(-6, 6); - endShape(CLOSE); - pop(); - } - - if (state === 'over') { - noStroke(); - textAlign(CENTER, CENTER); - textSize(28); - fill(C_SHIP); - text('GAME OVER', width / 2, height / 2 - 16); - textSize(14); - fill(C_BULLET); - text('PRESS BUTTON TO RESTART', width / 2, height / 2 + 16); - } + text('Qwiic Joystick', 12, 12); + fill(0, 240, 255); + textAlign(RIGHT, TOP); + text('X ' + nf(px, 4) + ' Y ' + nf(py, 4), width - 12, 12); + fill(pressed ? color(255, 215, 0) : color(90, 100, 140)); + textAlign(RIGHT, BOTTOM); + text(pressed ? '\u25CF BUTTON' : '\u25CB button', width - 12, height - 12); } -// fires per new serial line (kept for parity with the harness) -function serialEvent(line) {} +// fires on every new serial line (kept for parity with the harness) +function serialEvent(line) {} \ No newline at end of file diff --git a/docs/web-deploy.md b/docs/web-deploy.md new file mode 100644 index 0000000..6be5e1b --- /dev/null +++ b/docs/web-deploy.md @@ -0,0 +1,89 @@ +# Deploying the web build to Netlify (app.tinyStudio.cc) + +The same React renderer that ships in the desktop app is built as a static +bundle and hosted in the browser. This is the cloud-hosted IDE at +`app.tinyStudio.cc`. Compile/upload/serial still run through **tinyService** on +the user's own machine — the hosted page just connects to it over a local +WebSocket. + +## 1. What's in the repo + +- [`netlify.toml`](../netlify.toml) — build command (`npm run build:web`), publish + dir (`dist-web`), Node version, and the SPA fallback redirect that makes + `///` deep links work. +- The web build's asset base is `/` (see [`web.vite.config.mts`](../web.vite.config.mts)) + so assets resolve from any deep path. + +## 2. Connect the site in Netlify + +1. Netlify → **Add new site → Import an existing project** → pick this GitHub repo. +2. Build settings are read from `netlify.toml`; you shouldn't need to change them. + (If asked: build command `npm run build:web`, publish directory `dist-web`.) +3. Choose the branch to deploy (e.g. `main`). Deploy. + +You now have a `*.netlify.app` URL. Confirm the app loads and that a deep link +like `https://.netlify.app/Mister-Industries/tinyStudio/demo/Blink%20Example` +opens that project (proves the SPA redirect works). + +## 3. Point app.tinyStudio.cc at it + +1. Netlify → **Domain settings → Add a domain** → `app.tinyStudio.cc`. +2. At your DNS provider for `tinyStudio.cc`, add the record Netlify shows. Usually: + + | Type | Name | Value | + | ----- | ----- | ----------------------------- | + | CNAME | `app` | `.netlify.app` | + + (Netlify may instead give you their `apex`/`ALIAS` instructions — follow what + the dashboard shows for the subdomain.) +3. Netlify auto-provisions a Let's Encrypt certificate once DNS resolves. The + apex `tinyStudio.cc` is left free for the marketing homepage (a separate repo, + later). + +## 4. Deep links to GitHub projects + +Scheme: `app.tinyStudio.cc///` + +- `…/Mister-Industries/tinyStudio/demo/Blink%20Example` opens that folder. +- The folder is fetched from the repo's **default branch** via the GitHub API + + `raw.githubusercontent.com`, loaded into an in-memory workspace, and opened — + no local folder pick, no clone. +- Only public repos load anonymously. A signed-in GitHub token (the GitHub + button in the app) just raises the rate limit. Anonymous GitHub API is + 60 req/hr/IP; content comes from raw to stay mostly off that limit. + +## 5. Examples + +The **Examples** tab (in the right-hand docs panel) reads a manifest: + +- Default URL: `https://raw.githubusercontent.com/Mister-Industries/tinyStudio/main/examples.json` + — i.e. [`examples.json`](../examples.json) **on the `main` branch**. It must be + on `main` for the live site to see it. +- Each entry is `{ title, description, owner, repo, path, board? }`; clicking + **Open** loads it exactly like a deep link. + +### Moving to a dedicated examples repo (the intended end state) + +1. Create a public repo, e.g. `Mister-Industries/tinyStudio-examples`, with an + `examples.json` at its root and one folder per example (same project layout as + `demo/`: `sketch/sketch.ino`, `diagram.json`, `visual.js`, `README.md`). +2. Point each manifest entry's `owner`/`repo`/`path` at that repo. +3. Repoint `DEFAULT_MANIFEST_URL` in + [`ExamplesContent.tsx`](../src/renderer/src/components/ExamplesContent.tsx) to + the new repo's raw `examples.json`. + +## 6. Local overrides (for testing) + +Set these in the browser console / devtools `localStorage`: + +- `tinyservice.url` — point the app at a non-default backend + (default `ws://localhost:3000`). +- `tinystudio.examples.url` — point the Examples tab at a different manifest + (e.g. a branch or fork) before it's merged to `main`. + +## 7. Browser note (important) + +The hosted page is `https://`, but tinyService is `ws://localhost:3000` +(insecure WebSocket). **Chrome and Edge** treat `localhost` as trustworthy and +allow this; **Safari and some Firefox setups may block it** as mixed content. +The in-app "Start tinyService" banner recommends Chrome/Edge for this reason. diff --git a/examples.json b/examples.json new file mode 100644 index 0000000..f40960c --- /dev/null +++ b/examples.json @@ -0,0 +1,26 @@ +[ + { + "title": "Blink LED", + "description": "Blink the onboard LED and mirror its state in the Visual view.", + "owner": "Mister-Industries", + "repo": "tinyStudio", + "path": "demo/Blink Example", + "board": "tinyCore / Arduino" + }, + { + "title": "Fade LED", + "description": "PWM-fade an LED and chart the brightness curve live.", + "owner": "Mister-Industries", + "repo": "tinyStudio", + "path": "demo/Fade Example", + "board": "tinyCore / Arduino" + }, + { + "title": "Joystick", + "description": "Read a Qwiic joystick and visualize the stick position.", + "owner": "Mister-Industries", + "repo": "tinyStudio", + "path": "demo/Joystick Example", + "board": "tinyCore + Qwiic Joystick" + } +] diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..53eb2e7 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,26 @@ +# Netlify configuration for the tinyStudio web build (app.tinyStudio.cc). +# +# The same renderer that ships in the Electron desktop app is built as a static +# bundle by `npm run build:web` (see web.vite.config.mts) into dist-web/. Netlify +# serves that bundle. + +[build] + command = "npm run build:web" + publish = "dist-web" + +[build.environment] + # Pin Node so cloud builds match local dev (package.json requires Node 18+). + NODE_VERSION = "20" + # Skip lifecycle scripts on install: the web build needs neither the bundled + # arduino-cli (scripts/fetch-arduino-cli.mjs) nor the Electron native-deps + # rebuild (postinstall: electron-builder install-app-deps). Both are desktop- + # only and the latter can fail on Netlify's Linux builders. + NPM_FLAGS = "--ignore-scripts" + +# SPA fallback: deep links like /// are client-side routes, +# so every unmatched path must serve index.html (200, not a redirect) and let +# the app parse the URL. Static assets are matched before this rule. +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/package-lock.json b/package-lock.json index 2e40f48..4aacaec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "electron-updater": "^6.3.9", "express": "^4.18.2", "lucide-react": "^0.515.0", + "mermaid": "^11.15.0", "monaco-editor": "^0.52.2", "next-themes": "^0.4.6", "radix-ui": "^1.4.2", @@ -33,6 +34,7 @@ "react-redux": "^9.2.0", "react-resizable-panels": "^3.0.3", "react-zoom-pan-pinch": "^3.7.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.8", @@ -75,6 +77,19 @@ "node": ">=6.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.105.0", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz", @@ -422,6 +437,18 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -1701,6 +1728,23 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1837,6 +1881,15 @@ "node": ">= 10.0.0" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", + "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.1" + } + }, "node_modules/@mister-industries/shared": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@mister-industries/shared/-/shared-1.0.0.tgz", @@ -4278,6 +4331,259 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4312,6 +4618,12 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -4399,6 +4711,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4677,6 +4996,16 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.1.tgz", @@ -5996,6 +6325,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -6039,128 +6377,642 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, "engines": { - "node": ">=18" + "node": ">=0.10" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" + "cose-base": "^1.0.0" }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "cytoscape": "^3.2.0" } }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "cose-base": "^2.2.0" }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" + "peerDependencies": { + "cytoscape": "^3.2.0" } }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { - "ms": "^2.1.3" + "internmap": "1 - 2" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=12" } }, - "node_modules/decimal.js": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", - "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", - "license": "MIT" + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/decode-named-character-reference": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", - "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", - "license": "MIT", + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", "dependencies": { - "character-entities": "^2.0.0" + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=12" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", "dependencies": { - "mimic-response": "^3.1.0" + "d3-path": "1 - 3" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/decompress-response/node_modules/mimic-response": { + "node_modules/d3-color": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true, "license": "MIT", @@ -6224,6 +7076,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6373,6 +7234,15 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -7007,6 +7877,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz", + "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -8007,6 +8887,12 @@ "dev": true, "license": "MIT" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8310,6 +9196,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8359,6 +9255,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9065,6 +9970,31 @@ "node": ">=4.0" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9075,6 +10005,17 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", @@ -9346,6 +10287,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", @@ -9427,6 +10374,28 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -9450,6 +10419,34 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -9474,6 +10471,107 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -9631,6 +10729,35 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", + "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "es-toolkit": "^1.45.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -9709,6 +10836,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -10628,6 +11876,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10687,6 +11941,12 @@ "node": ">= 0.8" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10810,6 +12070,22 @@ "node": ">=10.4.0" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11505,6 +12781,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -11538,6 +12832,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -11685,6 +12994,12 @@ "node": ">=8.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -11729,6 +13044,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -11759,6 +13086,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -12453,6 +13786,12 @@ "inline-style-parser": "0.2.4" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -12653,6 +13992,15 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -12828,6 +14176,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 098cd6e..7a7baa7 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "electron-updater": "^6.3.9", "express": "^4.18.2", "lucide-react": "^0.515.0", + "mermaid": "^11.15.0", "monaco-editor": "^0.52.2", "next-themes": "^0.4.6", "radix-ui": "^1.4.2", @@ -57,6 +58,7 @@ "react-redux": "^9.2.0", "react-resizable-panels": "^3.0.3", "react-zoom-pan-pinch": "^3.7.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.8", diff --git a/src/main/index.ts b/src/main/index.ts index 425fb9c..a73e27b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -150,6 +150,11 @@ app.whenReady().then(async () => { await shell.openExternal(url) }) + // Default location for downloaded example projects (first-run onboarding). + ipcMain.handle('app:get-examples-dir', () => { + return path.join(app.getPath('documents'), 'tinyStudio Examples') + }) + // Save a generated file (e.g. the Visual web export) via a Save dialog. ipcMain.handle('save-file-as', async (event, defaultName: string, content: string) => { const window = BrowserWindow.fromWebContents(event.sender) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index f2e67ae..7cac8a6 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -128,6 +128,10 @@ interface SettingsAPI { clearApiKey: () => Promise } +interface AppAPI { + getExamplesDir: () => Promise +} + export type AgentEvent = | { type: 'text_delta'; text: string } | { type: 'tool_use'; id: string; name: string; input: unknown } @@ -167,6 +171,7 @@ declare global { arduino: ArduinoAPI settings: SettingsAPI agent: AgentAPI + app: AppAPI } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 97f9b04..9d13029 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -49,6 +49,12 @@ const api = { openExternal: (url: string): Promise => ipcRenderer.invoke('open-external', url) }, + // App-level paths/info. + app: { + // Default folder for downloaded example projects (Documents/tinyStudio Examples). + getExamplesDir: (): Promise => ipcRenderer.invoke('app:get-examples-dir') + }, + // App settings (Studio AI). The renderer never sees the API key value — // only whether one is configured. settings: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 07b8a7d..b94e4c4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,8 +1,12 @@ import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' -import { OpenWorkspaceCommand } from './commands/fileCommands' +import { BackendPrompt } from './components/BackendPrompt' +import { WelcomeDialog } from './components/WelcomeDialog' +import { LoadGitHubProjectCommand, OpenWorkspaceCommand } from './commands/fileCommands' +import { parseProjectRoute } from './lib/projectRouting' import { DocsPanel } from './components/DocsPanel' import { EditorPanel } from './components/EditorPanel' +import { ErrorBoundary } from './components/ErrorBoundary' import { FileExplorer } from './components/FileExplorer' import { Header } from './components/Header' import { SerialMonitor } from './components/SerialMonitor' @@ -12,8 +16,14 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './componen import { ArduinoProvider } from './contexts/ArduinoContext' import { SerialProvider } from './contexts/SerialContext' import { fileSystem } from './lib/fileSystem' -import { selectEditorView, selectPanelState, setPanelOpen, useAppDispatch, useAppSelector } from './redux' -import { ArduinoServiceFactory, getArduinoService } from './services/arduino/ArduinoServiceFactory' +import { + selectEditorView, + selectPanelState, + setPanelOpen, + useAppDispatch, + useAppSelector +} from './redux' +import { ArduinoServiceFactory } from './services/arduino/ArduinoServiceFactory' export default function App(): React.JSX.Element { const { isFileExplorerOpen, isSerialMonitorOpen, isDocsPanelOpen } = @@ -36,50 +46,33 @@ export default function App(): React.JSX.Element { } }, []) - // Surface a clear message when the tinyService backend isn't reachable, instead - // of letting compile/upload/serial fail silently. On desktop the app starts - // tinyService for you; in the browser the user runs it themselves — either way, - // if nothing is listening on the service URL we say so. - useEffect(() => { - const service = getArduinoService() - let warned = false - - const warnIfDown = (): void => { - if (service.isConnected() || warned) return - warned = true - toast.error('Arduino backend not running', { - description: - 'Compile, upload, and the serial monitor need tinyService. Start it and it will reconnect automatically.', - duration: 8000 - }) - } - - // Give the initial connection a few seconds before complaining. - const graceTimer = setTimeout(warnIfDown, 5000) - - const off = service.onConnectionChange((connected) => { - if (connected) { - if (warned) toast.success('Arduino backend connected') - warned = false - } else { - warnIfDown() - } - }) - - return () => { - clearTimeout(graceTimer) - off() - } - }, []) + // The "backend not reachable" messaging now lives in (a + // persistent, actionable banner with the start command) rather than a transient + // toast, so compile/upload/serial don't appear to fail silently. - // Reopen the last workspace on launch (if it still exists on disk). - // Guard against running twice — StrictMode double-invokes effects in dev, - // and a second OpenWorkspaceCommand rebuilds the tree with new ids, which - // previously opened a duplicate tab for the auto-opened sketch. + // On launch, a `///` deep link opens that GitHub project and + // takes precedence over reopening the last local workspace. Otherwise, reopen + // the last workspace (if it still exists on disk). + // + // Guard against running twice — StrictMode double-invokes effects in dev, and a + // second open rebuilds the tree with new ids, which previously opened a + // duplicate tab for the auto-opened sketch. const reopenedRef = useRef(false) useEffect(() => { if (reopenedRef.current) return reopenedRef.current = true + + const route = parseProjectRoute() + if (route) { + new LoadGitHubProjectCommand(route.owner, route.repo, route.path).execute().catch((e) => { + console.error('Failed to load project from URL:', e) + toast.error('Could not open that project', { + description: e instanceof Error ? e.message : String(e) + }) + }) + return + } + const last = localStorage.getItem('tinystudio.lastWorkspace') if (!last) return fileSystem @@ -94,51 +87,69 @@ export default function App(): React.JSX.Element { .catch((e) => console.error('Failed to reopen last workspace:', e)) }, []) + // Honor browser back/forward between projects. + useEffect(() => { + const onPop = (): void => { + const route = parseProjectRoute() + if (route) { + new LoadGitHubProjectCommand(route.owner, route.repo, route.path) + .execute() + .catch((e) => console.error('Failed to load project on navigation:', e)) + } + } + window.addEventListener('popstate', onPop) + return () => window.removeEventListener('popstate', onPop) + }, []) + return ( -
-
- - - {isFileExplorerOpen && ( - <> - - - - - - )} - - - setEditorSize(size)} - > - - - {isSerialMonitorOpen && ( - <> - - - - - - )} - - - {isDocsPanelOpen && ( - <> - - - - - - )} - - -
+
+
+ + + {isFileExplorerOpen && ( + <> + + + + + + )} + + + setEditorSize(size)} + > + + + {isSerialMonitorOpen && ( + <> + + + + + + )} + + + {isDocsPanelOpen && ( + <> + + + + + + + + )} + + + + +
) diff --git a/src/renderer/src/commands/fileCommands.ts b/src/renderer/src/commands/fileCommands.ts index 2e90cdb..c1a786a 100644 --- a/src/renderer/src/commands/fileCommands.ts +++ b/src/renderer/src/commands/fileCommands.ts @@ -1,5 +1,7 @@ import { Dispatch } from '@reduxjs/toolkit' import { fileSystem, FileSystemItem } from '@renderer/lib/fileSystem' +import { fetchRepoFolder, loadAccount } from '@renderer/lib/github' +import { VIRTUAL_PREFIX, virtualFileSystem } from '@renderer/lib/virtualFileSystem' import { BaseFileItem, closeFile, @@ -137,6 +139,58 @@ const combinePathAndFileName = (dirPath: string, fileName: string): string => { return dirPath + '/' + fileName } +const findFileInTree = ( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean +): BaseFileItem | null => { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) return item + if (item.children) { + const found = findFileInTree(item.children, match) + if (found) return found + } + } + return null +} + +/** + * Swap the open workspace to `workspace` and run the standard "just opened a + * project" side effects: load the README and diagram.svg into their panels, and + * auto-open the main .ino (or README) so the editor lands on real content. + * + * Shared by OpenWorkspaceCommand (local folder) and LoadGitHubProjectCommand + * (virtual workspace fetched from GitHub) — both end the same way. + */ +async function activateWorkspace(workspace: Workspace, dispatch: Dispatch): Promise { + const fileItems = workspace.root + + // Clear any previously open project first so switching doesn't carry over the + // old workspace's tabs/tree/README/diagram. + dispatch(closeWorkspace()) + dispatch(openWorkspace(workspace)) + + const readme = fileItems.find((file) => file.name === 'README.md') + if (readme) { + fileSystem + .readFile(readme.path) + .then((content) => dispatch(updateReadmeContent(content))) + .catch((e) => console.error('Failed to read README:', e)) + } + + const diagram = fileItems.find((file) => file.name === 'diagram.svg') + if (diagram) { + fileSystem + .readFile(diagram.path) + .then((content) => dispatch(updateDiagramSvgContent(content))) + .catch((e) => console.error('Failed to read diagram.svg:', e)) + } + + const sketch = + findFileInTree(fileItems, (i) => /\.ino$/i.test(i.name!)) || + findFileInTree(fileItems, (i) => i.name === 'README.md') + if (sketch) await new OpenFileCommand(sketch).execute() +} + export class OpenWorkspaceCommand implements Command { private filePath: string | undefined private get dispatch(): Dispatch { @@ -192,48 +246,59 @@ export class OpenWorkspaceCommand implements Command { root: fileItems } - // Clear any previously open project before showing the new one, so switching - // folders doesn't carry over the old workspace's tabs and tree state. Done - // only after a folder is confirmed above, so a cancelled picker is a no-op. - this.dispatch(closeWorkspace()) - this.dispatch(openWorkspace(workspace)) - - if (fileItems.some((file: BaseFileItem) => file.name === 'README.md')) { - // Open the README.md file if it exists - const readmeFile = fileItems.find((file: BaseFileItem) => file.name === 'README.md') - if (readmeFile) { - fileSystem.readFile(readmeFile.path).then((content) => { - this.dispatch(updateReadmeContent(content)) - }) - } - } + // Activation (close previous, open this, load README/diagram, auto-open the + // sketch) is only run after a folder is confirmed above, so a cancelled + // picker is a no-op. + await activateWorkspace(workspace, this.dispatch) + } +} - if (fileItems.some((file: BaseFileItem) => file.name === 'diagram.svg')) { - // Load the diagram.svg file if it exists - const diagramFile = fileItems.find((file: BaseFileItem) => file.name === 'diagram.svg') - if (diagramFile) { - fileSystem.readFile(diagramFile.path).then((content) => { - this.dispatch(updateDiagramSvgContent(content)) - }) - } +/** + * Open a project that lives in a public GitHub repo folder, without a local + * folder pick. Fetches `//` into the in-memory virtual file + * system and opens it as a `mem://` workspace. Powers `///` + * deep links and the Examples browser. + */ +export class LoadGitHubProjectCommand implements Command { + private get dispatch(): Dispatch { + return store.dispatch + } + constructor( + private owner: string, + private repo: string, + private path = '', + private branch?: string + ) {} + + async execute(): Promise { + // A signed-in token (if any) just raises the GitHub rate limit; public + // repos load fine without one. + const token = loadAccount()?.token + const files = await fetchRepoFolder(this.owner, this.repo, this.path, this.branch, token) + if (Object.keys(files).length === 0) { + throw new Error( + `No files found at ${this.owner}/${this.repo}${this.path ? '/' + this.path : ''}` + ) } - // Auto-open the sketch: prefer the main .ino, else the README, so opening a - // project lands you on editable code instead of an empty editor. - const findFile = (items: BaseFileItem[], match: (i: BaseFileItem) => boolean): BaseFileItem | null => { - for (const item of items) { - if (item.type === 'file' && item.name && match(item)) return item - if (item.children) { - const found = findFile(item.children, match) - if (found) return found - } - } - return null + const root = `${VIRTUAL_PREFIX}${this.owner}/${this.repo}${this.path ? '/' + this.path : ''}` + virtualFileSystem.clear(root) + await virtualFileSystem.seed(root, files) + // Overlay any prior in-editor edits saved to the cache for this project. + await virtualFileSystem.hydrateFromCache(root) + fileSystem.setCurrentWorkspace(root) + + const fsItems = await fileSystem.readDirectory(root, true) + const fileItems = buildNestedStructure(fsItems) + + const name = this.path ? this.path.split('/').pop()! : this.repo + const workspace: Workspace = { + id: crypto.randomUUID(), + name, + path: root, + root: fileItems } - const sketch = - findFile(fileItems, (i) => /\.ino$/i.test(i.name!)) || - findFile(fileItems, (i) => i.name === 'README.md') - if (sketch) await new OpenFileCommand(sketch).execute() + await activateWorkspace(workspace, this.dispatch) } } diff --git a/src/renderer/src/components/AIAssistant.tsx b/src/renderer/src/components/AIAssistant.tsx index 53fe15a..81f55e8 100644 --- a/src/renderer/src/components/AIAssistant.tsx +++ b/src/renderer/src/components/AIAssistant.tsx @@ -14,6 +14,7 @@ import { useAppDispatch, useAppSelector } from '@renderer/redux' +import { updateReadmeContent } from '@renderer/redux/fileSlice' import type { AgentEvent, AgentPermissionRequest } from '@renderer/lib/agentTypes' import { useArduinoContext } from '@renderer/contexts/ArduinoContext' import { @@ -33,6 +34,7 @@ import { } from 'lucide-react' import React from 'react' import { Button } from './ui/Button' +import { Markdown } from './Markdown' import { Dialog, DialogContent, @@ -53,6 +55,11 @@ type TimelineItem = const GREETING = "I'm Studio AI ✦ — I can read and edit the files in your open workspace. Ask me to explain code, wire a circuit, fix a build error, or write a sketch. I'll ask before changing any file." +// The agent runs in the Electron main process, reached via the preload bridge. +// In a plain browser (the web build) that bridge doesn't exist, so the panel +// degrades to an explanatory message instead of throwing. +const apiAvailable = (): boolean => typeof window !== 'undefined' && window.api != null + const TOOL_ICON: Record = { list_dir: , read_file: , @@ -83,11 +90,13 @@ export function AIAssistant(): React.JSX.Element { // Check whether an API key is configured. React.useEffect(() => { + if (!apiAvailable()) return window.api.settings.getStatus().then((s) => setKeyConfigured(s.configured)) }, []) // Subscribe to the agent's streamed events. React.useEffect(() => { + if (!apiAvailable()) return const off = window.api.agent.onEvent((evt: AgentEvent) => { setItems((prev) => applyEvent(prev, evt)) if (evt.type === 'done' || evt.type === 'error') setBusy(false) @@ -97,11 +106,13 @@ export function AIAssistant(): React.JSX.Element { // Surface permission prompts. React.useEffect(() => { + if (!apiAvailable()) return return window.api.agent.onPermissionRequest((req) => setPermission(req)) }, []) // When the agent changes a file that's open in the editor, reload it from disk. React.useEffect(() => { + if (!apiAvailable()) return return window.api.agent.onFileChanged(({ path }) => { const norm = path.replace(/\\/g, '/') const match = openFilesRef.current.find((f) => f.path.replace(/\\/g, '/') === norm) @@ -110,6 +121,14 @@ export function AIAssistant(): React.JSX.Element { .readFile(match.path) .then((content) => dispatch(refreshFileContentFromDisk({ id: match.id, content }))) } + // Keep the Documentation tab live when the agent rewrites the README — + // it reads from readmeContent, which otherwise only updates on a manual edit. + if (/(^|\/)README\.md$/i.test(norm)) { + window.api.fs + .readFile(path) + .then((content) => dispatch(updateReadmeContent(content))) + .catch((e) => console.error('Failed to refresh README:', e)) + } }) }, [dispatch]) @@ -156,6 +175,20 @@ export function AIAssistant(): React.JSX.Element { setPermission(null) } + // Web build: the agent lives in the Electron main process and isn't reachable + // from a browser. Show why instead of crashing on the missing bridge. + if (!apiAvailable()) { + return ( +
+ +

Studio AI is only available in the desktop app.

+

+ Open tinyStudio on your computer to let the assistant read and edit your project files. +

+
+ ) + } + return (
{/* Slim toolbar */} @@ -301,10 +334,10 @@ function TimelineRow({ item }: { item: TimelineItem }): React.JSX.Element { function AiBubble({ text }: { text: string }): React.JSX.Element { return (
- {text} + {text}
) } diff --git a/src/renderer/src/components/BackendPrompt.tsx b/src/renderer/src/components/BackendPrompt.tsx new file mode 100644 index 0000000..9a6a52e --- /dev/null +++ b/src/renderer/src/components/BackendPrompt.tsx @@ -0,0 +1,93 @@ +// "Run tinyService" prompt for the browser build. +// +// Compile / upload / serial all go through tinyService, a small local WebSocket +// backend (see WebSocketArduinoService.ts). The desktop app launches it for you; +// in the browser the user runs it themselves. This banner watches the live +// connection state and, when nothing is listening, shows a copy-paste command to +// start it. It hides automatically once connected, and is never shown in the +// Electron build (which auto-starts the backend). + +import { Check, Copy, Server, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { isElectron } from '@renderer/lib/utils' +import { getArduinoService } from '@renderer/services/arduino/ArduinoServiceFactory' +import { Button } from './ui/Button' + +const INSTALL_CMD = 'npx @mister-industries/tinyservice' + +export function BackendPrompt(): React.JSX.Element | null { + // Assume connected during the initial grace period so we don't flash the + // prompt before the first WebSocket attempt resolves. + const [connected, setConnected] = useState(true) + const [dismissed, setDismissed] = useState(false) + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (isElectron()) return // desktop starts tinyService itself + const service = getArduinoService() + + const grace = setTimeout(() => { + if (!service.isConnected()) setConnected(false) + }, 5000) + + const off = service.onConnectionChange((isConn) => { + setConnected(isConn) + // A fresh disconnect re-arms the prompt even if previously dismissed. + if (!isConn) setDismissed(false) + }) + + return () => { + clearTimeout(grace) + off() + } + }, []) + + if (isElectron() || connected || dismissed) return null + + const copy = (): void => { + navigator.clipboard + .writeText(INSTALL_CMD) + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + .catch(() => { + /* clipboard blocked; user can select the text manually */ + }) + } + + return ( +
+
+
+ + Start tinyService to build & flash +
+ +
+
+

+ Compiling, uploading, and the serial monitor run through a small local backend. Run this + in a terminal — the app reconnects automatically: +

+
+ {INSTALL_CMD} + +
+

+ Tip: use Chrome or{' '} + Edge — they allow the local + connection from a hosted page. +

+
+
+ ) +} diff --git a/src/renderer/src/components/DocsPanel.tsx b/src/renderer/src/components/DocsPanel.tsx index 05b967a..15cfcc3 100644 --- a/src/renderer/src/components/DocsPanel.tsx +++ b/src/renderer/src/components/DocsPanel.tsx @@ -1,6 +1,7 @@ import { setPanelOpen, useAppDispatch } from '@renderer/redux' -import { BookOpen, Sparkles, X } from 'lucide-react' +import { BookOpen, Sparkles, X, Zap } from 'lucide-react' import { AIAssistant } from './AIAssistant' +import { ExamplesContent } from './ExamplesContent' import { ReadmeContent } from './ReferenceContent' import { Button } from './ui/Button' import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/Tabs' @@ -22,7 +23,14 @@ export function DocsPanel(): React.JSX.Element { className="rounded-full px-3 py-1 text-xs text-fg-3 data-[state=active]:bg-navy-500 data-[state=active]:text-fg-1" > - README + Documentation + + + + Examples + + + diff --git a/src/renderer/src/components/EditorPanel.tsx b/src/renderer/src/components/EditorPanel.tsx index beb6ce8..08958b3 100644 --- a/src/renderer/src/components/EditorPanel.tsx +++ b/src/renderer/src/components/EditorPanel.tsx @@ -21,15 +21,32 @@ import { updateFileContent, updateReadmeContent } from '@renderer/redux/fileSlice' -import { CircuitBoard, CodeXml, ExternalLink, FolderOpen, Loader2, UploadCloud } from 'lucide-react' +import { + CircuitBoard, + CodeXml, + ExternalLink, + FolderOpen, + Loader2, + Plus, + UploadCloud +} from 'lucide-react' import * as monaco from 'monaco-editor' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { BlocklyEditor } from './BlocklyEditor' import { DiagramEditor } from './DiagramEditor' import { MonacoEditor, MonacoEditorRef } from './MonacoEditor' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from './ui/Dialog' import { FileTabContent, FileTabs, FileTabsList, FileTabTrigger } from './ui/FileTab' import { Button } from './ui/Button' +import { Input } from './ui/Input' import { VisualPreview } from './VisualPreview' loader.config({ monaco }) @@ -109,6 +126,46 @@ function findInTree( return null } +/** Collect every file in the tree matching `match`, skipping noise folders. */ +function collectFiles( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean, + out: BaseFileItem[] = [] +): BaseFileItem[] { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) out.push(item) + if (item.children && !['node_modules', '.git', 'dist'].includes(item.name ?? '')) { + collectFiles(item.children, match, out) + } + } + return out +} + +/** Starter p5.js sketch dropped into newly created .js files. */ +const sketchTemplate = (title: string): string => `// ${title} — p5.js sketch. +// Switch to Code to edit this sketch; Visual to run it. + +let x = 240; +let y = 140; +let dx = 3; +let dy = 2; + +function setup() { + createCanvas(480, 280); + noStroke(); +} + +function draw() { + background(7, 11, 34); + x += dx; + y += dy; + if (x < 20 || x > width - 20) dx = -dx; + if (y < 20 || y > height - 20) dy = -dy; + fill(0, 240, 255); + circle(x, y, 40); +} +` + /** * Ensure a project file (e.g. diagram.json / visual.js) is open as an editor * buffer, creating it from a default if it doesn't exist yet. Returns the open @@ -337,22 +394,58 @@ function CircuitView(): React.JSX.Element { function VisualView(): React.JSX.Element { const workspace = useAppSelector((s) => s.file.workspace) const dispatch = useAppDispatch() - const file = useProjectFile('visual.js', () => DEFAULT_VISUAL) + // Which .js sketch is shown in the Visual view. Defaults to visual.js, the + // sketch auto-created for every project; other .js files are selectable too. + const [activeSketch, setActiveSketch] = useState('visual.js') + const [showNew, setShowNew] = useState(false) // Serial is owned app-wide by SerialProvider and feeds the running sketch via // the shared bus — the Visual view no longer opens the port itself, so // switching to/from this view doesn't reset the board. const [publishing, setPublishing] = useState(false) + const jsFiles = useMemo( + () => (workspace ? collectFiles(workspace.root, (i) => /\.js$/i.test(i.name!)) : []), + [workspace] + ) + + // If the active sketch isn't in this project (after a project switch or a + // delete), fall back to visual.js or the first sketch available. + useEffect(() => { + if (jsFiles.length && !jsFiles.some((f) => f.name === activeSketch)) { + setActiveSketch(jsFiles.find((f) => f.name === 'visual.js')?.name ?? jsFiles[0].name!) + } + }, [jsFiles, activeSketch]) + + // Only visual.js gets seeded from a default; other sketches must already exist. + const file = useProjectFile( + activeSketch, + activeSketch === 'visual.js' ? () => DEFAULT_VISUAL : undefined + ) + if (!workspace) return - if (!file) return const ws = workspace + + const createSketch = async (fileName: string): Promise => { + const path = `${ws.path}/${fileName}` + try { + await fileSystem.writeFile(path, sketchTemplate(fileName.replace(/\.js$/i, ''))) + await new RefreshWorkspaceCommand(ws).execute() + setActiveSketch(fileName) + setShowNew(false) + } catch (e) { + toast.error('Could not create sketch', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } + } + // Build the standalone page, titled after the .ino project (not the folder). const buildHtml = (): string => { const ino = findInTree(ws.root, (i) => /\.ino$/i.test(i.name!)) const projectName = ino?.name?.replace(/\.ino$/i, '') || ws.name || 'tinyStudio sketch' - return buildVisualExportHtml(projectName, file.content) + return buildVisualExportHtml(projectName, file?.content ?? '') } // Preview: write index.html into the project root and open it in the browser. @@ -415,14 +508,42 @@ function VisualView(): React.JSX.Element { } return ( -
-
+
+ {/* Sketch picker + view actions. Tabs let you keep several p5.js renders + (asteroids.js, pong.js, …) in one project and switch between them. */} +
+
+ {jsFiles.map((f) => ( + + ))} + +
+
- + +
+ {file ? ( + + ) : ( + + )} +
+ + f.name!)} + onCreate={createSketch} + />
) } +function NewSketchDialog({ + open, + onOpenChange, + existing, + onCreate +}: { + open: boolean + onOpenChange: (o: boolean) => void + existing: string[] + onCreate: (fileName: string) => void +}): React.JSX.Element { + const [name, setName] = useState('') + + // Reset the field each time the dialog opens. + useEffect(() => { + if (open) setName('') + }, [open]) + + const trimmed = name.trim() + const fileName = trimmed ? (/\.js$/i.test(trimmed) ? trimmed : `${trimmed}.js`) : '' + const duplicate = !!fileName && existing.includes(fileName) + const invalid = !trimmed || /[\\/:*?"<>|]/.test(trimmed) || duplicate + + const submit = (): void => { + if (!invalid) onCreate(fileName) + } + + return ( + + + + New p5.js sketch + + Creates a new .js sketch in your project. Switch + between sketches with the tabs in the Visual view. + + +
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> + {duplicate && ( +

+ A file named {fileName} already exists. +

+ )} +
+ + + + +
+
+ ) +} + function EmptyHint({ label }: { icon: string; label: string }): React.JSX.Element { return (
diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..ef21c8f --- /dev/null +++ b/src/renderer/src/components/ErrorBoundary.tsx @@ -0,0 +1,65 @@ +/** + * ErrorBoundary — stops one component's crash from blanking the whole app. + * + * React unmounts the entire tree when an error escapes render or an effect, + * which previously left only the navy background ("blue screen"). This catches + * that error and shows a recoverable fallback instead. Use it at the app root + * and around any panel that can fail independently. + */ + +import { AlertTriangle } from 'lucide-react' +import React from 'react' +import { Button } from './ui/Button' + +interface Props { + children: React.ReactNode + /** Where the boundary sits, shown in the fallback (e.g. "Studio AI"). */ + label?: string + /** Optional custom fallback renderer. */ + fallback?: (error: Error, reset: () => void) => React.ReactNode +} + +interface State { + error: Error | null +} + +export class ErrorBoundary extends React.Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + console.error( + `ErrorBoundary${this.props.label ? ` (${this.props.label})` : ''} caught:`, + error, + info + ) + } + + reset = (): void => { + this.setState({ error: null }) + } + + render(): React.ReactNode { + const { error } = this.state + if (!error) return this.props.children + if (this.props.fallback) return this.props.fallback(error, this.reset) + + return ( +
+ +

+ {this.props.label ? `${this.props.label} hit an error.` : 'Something went wrong.'} +

+
+          {error.message}
+        
+ +
+ ) + } +} diff --git a/src/renderer/src/components/ExamplesContent.tsx b/src/renderer/src/components/ExamplesContent.tsx index 1bccd10..f593a69 100644 --- a/src/renderer/src/components/ExamplesContent.tsx +++ b/src/renderer/src/components/ExamplesContent.tsx @@ -1,91 +1,98 @@ -import { Zap } from 'lucide-react' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/Card' -import { ScrollArea } from './ui/ScrollArea' -import { Button } from './ui/Button' - -const arduinoExamples = [ - { - title: 'Blink LED', - description: 'Basic LED blinking example', - code: `void setup() { - pinMode(LED_BUILTIN, OUTPUT); -} +// Examples browser. Pulls a manifest of ready-to-open projects from the public +// examples repo and opens any of them straight into the editor (via the virtual +// workspace) — no local folder pick, no clone. Each card maps to a +// /// deep link. -void loop() { - digitalWrite(LED_BUILTIN, HIGH); - delay(1000); - digitalWrite(LED_BUILTIN, LOW); - delay(1000); -}` - }, - { - title: 'Button Input', - description: 'Read button state and control LED', - code: `const int buttonPin = 2; -const int ledPin = 13; - -void setup() { - pinMode(ledPin, OUTPUT); - pinMode(buttonPin, INPUT); -} - -void loop() { - int buttonState = digitalRead(buttonPin); - if (buttonState == HIGH) { - digitalWrite(ledPin, HIGH); - } else { - digitalWrite(ledPin, LOW); - } -}` - }, - { - title: 'Servo Motor', - description: 'Control servo motor position', - code: `#include +import { Loader2, Zap } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { fetchExamplesManifest, type ExampleEntry } from '@renderer/lib/examples' +import { navigateToProject } from '@renderer/lib/projectRouting' +import { Button } from './ui/Button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/Card' +import { ScrollArea } from './ui/ScrollArea' -Servo myservo; +export function ExamplesContent(): React.JSX.Element { + const [examples, setExamples] = useState([]) + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [openingPath, setOpeningPath] = useState(null) -void setup() { - myservo.attach(9); -} + useEffect(() => { + let cancelled = false + fetchExamplesManifest() + .then((data) => { + if (cancelled) return + setExamples(data) + setStatus('ready') + }) + .catch(() => { + if (!cancelled) setStatus('error') + }) + return () => { + cancelled = true + } + }, []) -void loop() { - myservo.write(90); - delay(1000); - myservo.write(0); - delay(1000); -}` + const open = async (ex: ExampleEntry): Promise => { + const key = `${ex.owner}/${ex.repo}/${ex.path}` + setOpeningPath(key) + try { + await navigateToProject(ex.owner, ex.repo, ex.path) + } catch (e) { + toast.error('Could not open example', { + description: e instanceof Error ? e.message : String(e) + }) + } finally { + setOpeningPath(null) + } } -] -export function ExamplesContent(): React.JSX.Element { return ( <>
- tinyCore Examples + Examples
-
- {arduinoExamples.map((example, index) => ( - - - {example.title} - {example.description} - - -
-                  {example.code}
-                
-
- - - -
- ))} +
+ {status === 'loading' && ( +
+ + Loading examples… +
+ )} + + {status === 'error' && ( +

+ Couldn't load examples. Check your connection and try again. +

+ )} + + {status === 'ready' && examples.length === 0 && ( +

No examples available yet.

+ )} + + {examples.map((example) => { + const key = `${example.owner}/${example.repo}/${example.path}` + const opening = openingPath === key + return ( + + + {example.title} + {example.description} + + + {example.board && ( +

Board: {example.board}

+ )} + +
+
+ ) + })}
diff --git a/src/renderer/src/components/Markdown.tsx b/src/renderer/src/components/Markdown.tsx new file mode 100644 index 0000000..c4fff03 --- /dev/null +++ b/src/renderer/src/components/Markdown.tsx @@ -0,0 +1,254 @@ +/** + * Markdown — shared GitHub-flavored markdown renderer. + * + * Used by both the Documentation tab and the Studio AI chat so they stylize + * markdown the same way. Fenced ```mermaid blocks are rendered as diagrams + * instead of code; everything else falls back to themed HTML elements. + */ + +import mermaid from 'mermaid' +import React from 'react' +import ReactMarkdown, { type Components } from 'react-markdown' +import remarkGfm from 'remark-gfm' + +// Initialize mermaid once for the whole renderer. `startOnLoad: false` because +// we drive rendering ourselves from . +let mermaidReady = false +function ensureMermaid(): void { + if (mermaidReady) return + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'dark', + themeVariables: { + fontFamily: 'inherit', + primaryColor: '#1e2a4a', + primaryTextColor: '#e6ecff', + primaryBorderColor: '#3a4a6a', + lineColor: '#5a6a8a', + secondaryColor: '#243156', + tertiaryColor: '#1a2540' + } + }) + mermaidReady = true +} + +/** Recursively flatten React markdown children into plain text. */ +function childrenToText(children: React.ReactNode): string { + if (children == null) return '' + if (typeof children === 'string' || typeof children === 'number') return String(children) + if (Array.isArray(children)) return children.map(childrenToText).join('') + if (React.isValidElement(children)) { + return childrenToText((children.props as { children?: React.ReactNode }).children) + } + return '' +} + +/** Inspect a
's child  element for its language and source text. */
+function getCodeInfo(children: React.ReactNode): { lang: string | null; text: string } | null {
+  const child = React.Children.toArray(children)[0]
+  if (!React.isValidElement(child)) return null
+  const props = child.props as { className?: string; children?: React.ReactNode }
+  const match = /language-(\w+)/.exec(props.className ?? '')
+  return { lang: match ? match[1] : null, text: childrenToText(props.children) }
+}
+
+function MermaidDiagram({ chart }: { chart: string }): React.JSX.Element {
+  const ref = React.useRef(null)
+  const [error, setError] = React.useState(null)
+
+  React.useEffect(() => {
+    ensureMermaid()
+    let cancelled = false
+    const id = `mermaid-${Math.random().toString(36).slice(2)}`
+    mermaid
+      .render(id, chart)
+      .then(({ svg }) => {
+        if (!cancelled && ref.current) ref.current.innerHTML = svg
+      })
+      .catch((e: unknown) => {
+        if (!cancelled) setError(e instanceof Error ? e.message : String(e))
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [chart])
+
+  if (error) {
+    return (
+      
+        {chart}
+      
+ ) + } + return
+} + +const components: Components = { + h1: ({ children, className, ...props }) => ( +

+ {children} +

+ ), + h2: ({ children, className, ...props }) => ( +

+ {children} +

+ ), + h3: ({ children, className, ...props }) => ( +

+ {children} +

+ ), + p: ({ children, className, ...props }) => ( +

+ {children} +

+ ), + ul: ({ children, className, ...props }) => ( +
    + {children} +
+ ), + ol: ({ children, className, ...props }) => ( +
    + {children} +
+ ), + li: ({ children, className, ...props }) => ( +
  • + {children} +
  • + ), + strong: ({ children, className, ...props }) => ( + + {children} + + ), + em: ({ children, className, ...props }) => ( + + {children} + + ), + code: ({ children, className, ...props }) => ( + + {children} + + ), + pre: ({ children, className, ...props }) => { + const info = getCodeInfo(children) + if (info?.lang === 'mermaid') return + return ( +
    code]:bg-transparent [&>code]:p-0 [&>code]:text-fg-2'
    +        ]
    +          .filter(Boolean)
    +          .join(' ')}
    +      >
    +        {children}
    +      
    + ) + }, + blockquote: ({ children, className, ...props }) => ( +
    + {children} +
    + ), + a: ({ children, className, ...props }) => ( + + {children} + + ), + hr: ({ className, ...props }) => ( +
    + ), + table: ({ children, className, ...props }) => ( +
    + + {children} +
    +
    + ), + th: ({ children, className, ...props }) => ( + + {children} + + ), + td: ({ children, className, ...props }) => ( + + {children} + + ) +} + +export function Markdown({ + children, + className +}: { + children: string + className?: string +}): React.JSX.Element { + return ( +
    *:last-child]:mb-0', className].filter(Boolean).join(' ')}> + + {children} + +
    + ) +} diff --git a/src/renderer/src/components/ReferenceContent.tsx b/src/renderer/src/components/ReferenceContent.tsx index 6e4ae90..714e5a5 100644 --- a/src/renderer/src/components/ReferenceContent.tsx +++ b/src/renderer/src/components/ReferenceContent.tsx @@ -1,119 +1,9 @@ import { useAppSelector } from '@renderer/redux' import { Folder } from 'lucide-react' -import type { Components } from 'react-markdown' -import ReactMarkdown from 'react-markdown' +import { Markdown } from './Markdown' export function ReadmeContent(): React.JSX.Element { const readmeContent = useAppSelector((state) => state.file.readmeContent) - const components: Components = { - h1: ({ children, className, ...props }) => ( -

    - {children} -

    - ), - h2: ({ children, className, ...props }) => ( -

    - {children} -

    - ), - h3: ({ children, className, ...props }) => ( -

    - {children} -

    - ), - p: ({ children, className, ...props }) => ( -

    - {children} -

    - ), - ul: ({ children, className, ...props }) => ( -
      - {children} -
    - ), - ol: ({ children, className, ...props }) => ( -
      - {children} -
    - ), - li: ({ children, className, ...props }) => ( -
  • - {children} -
  • - ), - strong: ({ children, className, ...props }) => ( - - {children} - - ), - em: ({ children, className, ...props }) => ( - - {children} - - ), - code: ({ children, className, ...props }) => ( - - {children} - - ), - pre: ({ children, className, ...props }) => ( -
    -        {children}
    -      
    - ), - blockquote: ({ children, className, ...props }) => ( -
    - {children} -
    - ), - a: ({ children, className, ...props }) => ( - - {children} - - ) - } if (readmeContent === null || readmeContent === '' || readmeContent === undefined) { return (
    @@ -124,9 +14,7 @@ export function ReadmeContent(): React.JSX.Element { } return (
    -
    - {readmeContent} -
    + {readmeContent}
    ) } diff --git a/src/renderer/src/components/VisualPreview.tsx b/src/renderer/src/components/VisualPreview.tsx index 8d78aac..635ae8b 100644 --- a/src/renderer/src/components/VisualPreview.tsx +++ b/src/renderer/src/components/VisualPreview.tsx @@ -143,9 +143,11 @@ export function VisualPreview({ code, name }: { code: string; name: string }): R P5.JS · {name.toUpperCase()}
    - - {err ? 'error' : !hasP5 ? 'loading p5…' : running ? '60 fps' : 'paused'} - + {(err || !hasP5 || !running) && ( + + {err ? 'error' : !hasP5 ? 'loading p5…' : 'paused'} + + )}
    {code ? ( diff --git a/src/renderer/src/components/WelcomeDialog.tsx b/src/renderer/src/components/WelcomeDialog.tsx new file mode 100644 index 0000000..e298b89 --- /dev/null +++ b/src/renderer/src/components/WelcomeDialog.tsx @@ -0,0 +1,136 @@ +// First-run onboarding. On the very first launch we offer to get the user +// started with example projects: +// • Desktop — download the latest examples to Documents/tinyStudio Examples and +// open them, so they're on disk and ready to flash. +// • Web — there's nothing to install; we point the user at the live Examples tab. +// Shown once (gated by localStorage), and skipped entirely when the app was +// opened via a /// deep link (the user came for a project). + +import { Download, Loader2, Zap } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' +import { OpenWorkspaceCommand } from '@renderer/commands/fileCommands' +import { installExamplesToDisk } from '@renderer/lib/examples' +import { parseProjectRoute } from '@renderer/lib/projectRouting' +import { isElectron } from '@renderer/lib/utils' +import { setPanelOpen, useAppDispatch } from '@renderer/redux' +import { Button } from './ui/Button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from './ui/Dialog' + +const ONBOARDED_KEY = 'tinystudio.onboarded' + +function markOnboarded(): void { + try { + localStorage.setItem(ONBOARDED_KEY, '1') + } catch { + /* ignore */ + } +} + +function shouldShow(): boolean { + try { + if (localStorage.getItem(ONBOARDED_KEY)) return false + } catch { + return false + } + // Opened via a deep link → the user came for a specific project, not a welcome. + return !parseProjectRoute() +} + +export function WelcomeDialog(): React.JSX.Element { + const dispatch = useAppDispatch() + const [open, setOpen] = useState(shouldShow) + const [busy, setBusy] = useState(false) + const [progress, setProgress] = useState('') + + const desktop = isElectron() + + const skip = (): void => { + markOnboarded() + setOpen(false) + } + + // Desktop: download examples to disk and open them. + const download = async (): Promise => { + setBusy(true) + setProgress('Preparing…') + try { + const { dir, installed } = await installExamplesToDisk(setProgress) + markOnboarded() + setOpen(false) + toast.success(`Installed ${installed} example${installed === 1 ? '' : 's'}`, { + description: dir + }) + await new OpenWorkspaceCommand(dir).execute() + } catch (e) { + // Keep the dialog open so the user can retry or skip. + toast.error('Could not download examples', { + description: e instanceof Error ? e.message : String(e) + }) + setBusy(false) + setProgress('') + } + } + + // Web: nothing to install — open the docs panel where the Examples tab lives. + const browse = (): void => { + markOnboarded() + setOpen(false) + dispatch(setPanelOpen({ panel: 'docs', isOpen: true })) + toast('Browse ready-made projects in the Examples tab.') + } + + return ( + { + if (!v && !busy) skip() + }} + > + + + + + Welcome to tinyStudio + + + {desktop + ? 'Start with a set of example projects you can open, edit, wire up, and flash. We can download the latest ones for you now.' + : 'Browse ready-made example projects and open any of them in one click.'} + + + + {busy && ( +
    + + {progress || 'Downloading…'} +
    + )} + + + + {desktop ? ( + + ) : ( + + )} + +
    +
    + ) +} diff --git a/src/renderer/src/lib/examples.ts b/src/renderer/src/lib/examples.ts new file mode 100644 index 0000000..24c1a86 --- /dev/null +++ b/src/renderer/src/lib/examples.ts @@ -0,0 +1,82 @@ +// Examples manifest + desktop installer. +// +// The Examples tab (ExamplesContent) and the first-run onboarding (WelcomeDialog) +// both read the same manifest of ready-to-open projects. On the desktop build the +// onboarding can also *download* them to a local folder so they live on disk and +// can be flashed straight away. + +import { fetchRepoFolder } from './github' +import { fileSystem } from './fileSystem' + +// One project the user can open. `owner/repo/path` are GitHub coordinates, so +// examples may live across multiple repos. +export interface ExampleEntry { + title: string + description: string + owner: string + repo: string + path: string + board?: string +} + +// Where the manifest lives. Interim: examples.json at the root of the main repo +// (must be on `main` for this raw URL to resolve). When the dedicated public +// examples repo is set up, repoint this to +// https://raw.githubusercontent.com/Mister-Industries/tinyStudio-examples/main/examples.json. +// Overridable (like tinyservice.url) for testing against a fork or branch via +// localStorage["tinystudio.examples.url"]. +const DEFAULT_MANIFEST_URL = + 'https://raw.githubusercontent.com/Mister-Industries/tinyStudio/main/examples.json' + +export function resolveManifestUrl(): string { + try { + return localStorage.getItem('tinystudio.examples.url') || DEFAULT_MANIFEST_URL + } catch { + return DEFAULT_MANIFEST_URL + } +} + +/** Fetch and parse the examples manifest. */ +export async function fetchExamplesManifest(): Promise { + const r = await fetch(resolveManifestUrl()) + if (!r.ok) throw new Error(`Manifest ${r.status}`) + const data = await r.json() + return Array.isArray(data) ? (data as ExampleEntry[]) : [] +} + +/** Folder name to store an example under (its path basename, else the repo). */ +function exampleFolderName(ex: ExampleEntry): string { + const base = ex.path ? ex.path.split('/').filter(Boolean).pop() : '' + return base || ex.repo +} + +/** + * Download every example in the manifest into the desktop app's default examples + * folder (Documents/tinyStudio Examples). Desktop-only — the browser build + * browses examples live via the Examples tab instead. Returns the target folder + * and how many projects were written. + */ +export async function installExamplesToDisk( + onProgress?: (msg: string) => void +): Promise<{ dir: string; installed: number }> { + if (!fileSystem.isElectron()) { + throw new Error('Example download is only available in the desktop app.') + } + + const dir = await window.api.app.getExamplesDir() + const manifest = await fetchExamplesManifest() + let installed = 0 + + for (let i = 0; i < manifest.length; i++) { + const ex = manifest[i] + onProgress?.(`Downloading ${i + 1}/${manifest.length} · ${ex.title}`) + const files = await fetchRepoFolder(ex.owner, ex.repo, ex.path) + const folder = `${dir}/${exampleFolderName(ex)}` + for (const [rel, content] of Object.entries(files)) { + await fileSystem.writeFile(`${folder}/${rel}`, content) + } + if (Object.keys(files).length > 0) installed++ + } + + return { dir, installed } +} diff --git a/src/renderer/src/lib/fileSystem.ts b/src/renderer/src/lib/fileSystem.ts index 4078aae..1e37608 100644 --- a/src/renderer/src/lib/fileSystem.ts +++ b/src/renderer/src/lib/fileSystem.ts @@ -3,6 +3,7 @@ import { webCache } from './webCache' import { webFileSystem, type FileStats, type FileSystemItem } from './webFileSystem' +import { isVirtualPath, virtualFileSystem } from './virtualFileSystem' // Environment detection const isElectron = (): boolean => { @@ -56,6 +57,11 @@ class UnifiedFileSystemService implements UnifiedFileSystemAPI { // Read directory contents async readDirectory(dirPath = '', recursive = false): Promise { try { + // A mem:// path (or a virtual current workspace) is served in-memory, + // regardless of Electron vs web — examples/deep links use this. + if (isVirtualPath(dirPath) || (!dirPath && isVirtualPath(this.currentWorkspace))) { + return await virtualFileSystem.readDirectory(dirPath || this.currentWorkspace!, recursive) + } if (this.isElectron()) { const targetPath = dirPath || this.currentWorkspace if (!targetPath) { @@ -74,6 +80,9 @@ class UnifiedFileSystemService implements UnifiedFileSystemAPI { // Read file content async readFile(filePath: string): Promise { try { + if (isVirtualPath(filePath)) { + return await virtualFileSystem.readFile(filePath) + } if (this.isElectron()) { return await window.api.fs.readFile(filePath) } else { @@ -97,6 +106,10 @@ class UnifiedFileSystemService implements UnifiedFileSystemAPI { // Write file content async writeFile(filePath: string, content: string): Promise { try { + if (isVirtualPath(filePath)) { + await virtualFileSystem.writeFile(filePath, content) + return + } if (this.isElectron()) { await window.api.fs.writeFile(filePath, content) } else { @@ -113,6 +126,10 @@ class UnifiedFileSystemService implements UnifiedFileSystemAPI { // Create new file async createFile(filePath: string, content = ''): Promise { try { + if (isVirtualPath(filePath)) { + await virtualFileSystem.createFile(filePath, content) + return + } if (this.isElectron()) { await window.api.fs.createFile(filePath, content) } else { @@ -127,6 +144,10 @@ class UnifiedFileSystemService implements UnifiedFileSystemAPI { async renameFile(oldPath: string, newPath: string): Promise { try { + if (isVirtualPath(oldPath) || isVirtualPath(newPath)) { + await virtualFileSystem.renameFile(oldPath, newPath) + return + } if (this.isElectron()) { await window.api.fs.renameFile(oldPath, newPath) } else { @@ -142,6 +163,10 @@ class UnifiedFileSystemService implements UnifiedFileSystemAPI { // Create new folder async createFolder(folderPath: string): Promise { try { + if (isVirtualPath(folderPath)) { + await virtualFileSystem.createFolder(folderPath) + return + } if (this.isElectron()) { await window.api.fs.createFolder(folderPath) } else { @@ -156,6 +181,10 @@ class UnifiedFileSystemService implements UnifiedFileSystemAPI { // Delete file or directory async deleteFile(targetPath: string): Promise { try { + if (isVirtualPath(targetPath)) { + await virtualFileSystem.deleteFile(targetPath) + return + } if (this.isElectron()) { await window.api.fs.deleteFile(targetPath) } else { @@ -171,6 +200,9 @@ class UnifiedFileSystemService implements UnifiedFileSystemAPI { // Check if path exists async pathExists(targetPath: string): Promise { try { + if (isVirtualPath(targetPath)) { + return await virtualFileSystem.pathExists(targetPath) + } if (this.isElectron()) { return await window.api.fs.pathExists(targetPath) } else { @@ -185,6 +217,9 @@ class UnifiedFileSystemService implements UnifiedFileSystemAPI { // Get file stats async getFileStats(filePath: string): Promise { try { + if (isVirtualPath(filePath)) { + return await virtualFileSystem.getFileStats(filePath) + } if (this.isElectron()) { return await window.api.fs.getFileStats(filePath) } else { diff --git a/src/renderer/src/lib/github.ts b/src/renderer/src/lib/github.ts index f3fb480..18a13cc 100644 --- a/src/renderer/src/lib/github.ts +++ b/src/renderer/src/lib/github.ts @@ -99,6 +99,43 @@ async function ghFile(owner: string, repo: string, path: string, branch: string) return r.text() } +/** + * Fetch a single folder (a project) out of a public repo as a flat + * { relpath: content } map, where relpath is relative to `folder`. Used to open + * `///` deep links and Examples in the editor without a local + * folder pick. Only text files (TEXT_EXT) under ~200 KB are pulled; content + * comes from raw.githubusercontent to stay off the GitHub API rate limit. + */ +export async function fetchRepoFolder( + owner: string, + repo: string, + folder = '', + branch?: string, + token?: string +): Promise> { + const resolvedBranch = branch || (await ghRepoMeta(owner, repo, token)).branch + const base = folder.replace(/^\/+|\/+$/g, '') // normalize, no leading/trailing slash + const prefix = base ? base + '/' : '' + const blobs = await ghTree(owner, repo, resolvedBranch, token) + const wanted = blobs.filter( + (b) => + (base === '' || b.path === base || b.path.startsWith(prefix)) && + TEXT_EXT.includes(extOf(b.path)) && + b.size < 200000 + ) + const out: Record = {} + for (const b of wanted) { + const rel = base ? b.path.slice(prefix.length) : b.path + if (!rel) continue + try { + out[rel] = await ghFile(owner, repo, b.path, resolvedBranch) + } catch { + /* skip unreadable blob */ + } + } + return out +} + function b64encode(str: string): string { return btoa(unescape(encodeURIComponent(str))) } diff --git a/src/renderer/src/lib/projectRouting.ts b/src/renderer/src/lib/projectRouting.ts new file mode 100644 index 0000000..bf1c25a --- /dev/null +++ b/src/renderer/src/lib/projectRouting.ts @@ -0,0 +1,54 @@ +// Deep-link routing for opening GitHub projects in the editor. +// +// URL scheme: app.tinyStudio.cc/// +// e.g. /Mister-Industries/tinyStudio-examples/blink +// +// There is no router dependency — the app parses window.location on startup (and +// on back/forward) and loads the matching project via LoadGitHubProjectCommand. +// The Netlify SPA redirect (netlify.toml) ensures the deep URL serves index.html +// so this code can run. + +import { LoadGitHubProjectCommand } from '@renderer/commands/fileCommands' + +export interface ProjectRoute { + owner: string + repo: string + path: string +} + +/** Parse a pathname into a project route, or null if it isn't one. */ +export function parseProjectRoute( + pathname: string = window.location.pathname +): ProjectRoute | null { + const segs = pathname + .replace(/^\/+|\/+$/g, '') + .split('/') + .filter(Boolean) + .map(decodeURIComponent) + if (segs.length < 2) return null + const [owner, repo, ...rest] = segs + return { owner, repo, path: rest.join('/') } +} + +/** Build the URL path for a project route. */ +export function projectRoutePath(owner: string, repo: string, path = ''): string { + return ( + '/' + + [owner, repo, path] + .filter(Boolean) + .map((s) => s.split('/').map(encodeURIComponent).join('/')) + .join('/') + ) +} + +/** + * Push a project URL into history and load it. Used by the Examples browser and + * any in-app "open this project" affordance. + */ +export async function navigateToProject(owner: string, repo: string, path = ''): Promise { + const url = projectRoutePath(owner, repo, path) + if (url !== window.location.pathname) { + window.history.pushState({ owner, repo, path }, '', url) + } + await new LoadGitHubProjectCommand(owner, repo, path).execute() +} diff --git a/src/renderer/src/lib/virtualFileSystem.ts b/src/renderer/src/lib/virtualFileSystem.ts new file mode 100644 index 0000000..6663754 --- /dev/null +++ b/src/renderer/src/lib/virtualFileSystem.ts @@ -0,0 +1,218 @@ +// Virtual (in-memory) file system for the browser build. +// +// The web build normally uses the File System Access API (webFileSystem.ts), +// which requires the user to *pick a real local folder*. That's wrong for +// projects we load on the user's behalf — examples and `///` +// deep links — where there is no local folder to pick. +// +// This backend holds a project's files and folders in memory under a synthetic +// `mem://` root, exposing the same surface UnifiedFileSystemService needs. Every +// write is mirrored into webCache (IndexedDB) so edits survive a reload, and +// readFile falls back to the cache. The unified service (fileSystem.ts) routes +// any path beginning with `mem://` here; everything else is unchanged. + +import { webCache } from './webCache' +import type { FileStats, FileSystemItem } from './webFileSystem' + +export const VIRTUAL_PREFIX = 'mem://' + +/** True for any path rooted in the virtual (in-memory) file system. */ +export function isVirtualPath(p?: string | null): boolean { + return !!p && p.startsWith(VIRTUAL_PREFIX) +} + +const stripTrailingSlash = (p: string): string => (p.endsWith('/') ? p.slice(0, -1) : p) + +class VirtualFileSystemService { + private files = new Map() // full path -> content + private folders = new Set() // full folder paths + private mtimes = new Map() // full path -> last modified + + /** The parent directory of a path, or null at/above the mem:// root. */ + private parentOf(path: string): string | null { + const norm = stripTrailingSlash(path) + const slash = norm.lastIndexOf('/') + // `mem://owner` has its last slash inside the scheme — stop there. + if (slash <= VIRTUAL_PREFIX.length - 1) return null + return norm.slice(0, slash) + } + + /** Register every ancestor folder of a file/folder path. */ + private addAncestors(path: string): void { + let parent = this.parentOf(path) + while (parent && parent !== stripTrailingSlash(VIRTUAL_PREFIX)) { + if (!this.folders.has(parent)) this.folders.add(parent) + parent = this.parentOf(parent) + } + } + + /** + * Bulk-load a project's base content into memory. `rootPath` is the mem:// + * workspace root; `files` maps paths relative to that root to their text + * content. Intentionally does NOT touch webCache — the cache holds only the + * user's in-editor edits (written via writeFile), so hydrateFromCache can + * overlay them on top of this freshly-fetched base after a reload. + */ + async seed(rootPath: string, files: Record): Promise { + const root = stripTrailingSlash(rootPath) + this.folders.add(root) + const now = Date.now() + for (const [rel, content] of Object.entries(files)) { + const full = `${root}/${rel.replace(/^\/+/, '')}` + this.files.set(full, content) + this.mtimes.set(full, now) + this.addAncestors(full) + } + } + + /** + * Restore any edited files for this root from webCache, overlaying the seeded + * base. Lets in-editor changes survive a reload of a deep-linked project. + */ + async hydrateFromCache(rootPath: string): Promise { + const root = stripTrailingSlash(rootPath) + const keys = await webCache.keys() + for (const key of keys) { + if (key !== root && !key.startsWith(root + '/')) continue + const content = await webCache.get(key) + if (content === null) continue + this.files.set(key, content) + this.addAncestors(key) + } + } + + async readDirectory(dirPath = '', recursive = false): Promise { + const dir = stripTrailingSlash(dirPath) + const prefix = dir + '/' + const seen = new Map() + + const consider = (full: string, isDirectory: boolean): void => { + if (!full.startsWith(prefix)) return + if (recursive) { + if (!seen.has(full)) seen.set(full, this.toItem(full, isDirectory)) + return + } + // Non-recursive: only direct children of `dir`. + const rest = full.slice(prefix.length) + const slash = rest.indexOf('/') + if (slash === -1) { + if (!seen.has(full)) seen.set(full, this.toItem(full, isDirectory)) + } else { + // A deeper path implies an intermediate folder directly under `dir`. + const childDir = prefix + rest.slice(0, slash) + if (!seen.has(childDir)) seen.set(childDir, this.toItem(childDir, true)) + } + } + + for (const folder of this.folders) consider(folder, true) + for (const file of this.files.keys()) consider(file, false) + return [...seen.values()] + } + + private toItem(full: string, isDirectory: boolean): FileSystemItem { + const name = full.slice(full.lastIndexOf('/') + 1) + const content = this.files.get(full) + return { + name, + path: full, + isDirectory, + size: content != null ? content.length : undefined, + lastModified: this.mtimes.get(full) + } + } + + async readFile(filePath: string): Promise { + const inMem = this.files.get(filePath) + if (inMem != null) return inMem + const cached = await webCache.get(filePath) + if (cached !== null) { + this.files.set(filePath, cached) + return cached + } + throw new Error(`File not found: ${filePath}`) + } + + async writeFile(filePath: string, content: string): Promise { + this.files.set(filePath, content) + this.mtimes.set(filePath, Date.now()) + this.addAncestors(filePath) + void webCache.put(filePath, content) + } + + async createFile(filePath: string, content = ''): Promise { + if (this.files.has(filePath)) throw new Error('File already exists') + await this.writeFile(filePath, content) + } + + async createFolder(folderPath: string): Promise { + const folder = stripTrailingSlash(folderPath) + this.folders.add(folder) + this.addAncestors(folder) + } + + async deleteFile(targetPath: string): Promise { + const target = stripTrailingSlash(targetPath) + // Folder delete: remove it and everything beneath it. + if (this.folders.has(target)) { + this.folders.delete(target) + const childPrefix = target + '/' + for (const f of [...this.files.keys()]) { + if (f.startsWith(childPrefix)) { + this.files.delete(f) + this.mtimes.delete(f) + void webCache.remove(f) + } + } + for (const d of [...this.folders]) if (d.startsWith(childPrefix)) this.folders.delete(d) + return + } + this.files.delete(target) + this.mtimes.delete(target) + void webCache.remove(target) + } + + async renameFile(oldPath: string, newPath: string): Promise { + const content = await this.readFile(oldPath) + await this.writeFile(newPath, content) + await this.deleteFile(oldPath) + } + + async pathExists(targetPath: string): Promise { + const p = stripTrailingSlash(targetPath) + return this.files.has(p) || this.folders.has(p) + } + + async getFileStats(filePath: string): Promise { + const content = await this.readFile(filePath) + const mtime = this.mtimes.get(filePath) ?? Date.now() + return { + isDirectory: false, + isFile: true, + size: content.length, + lastModified: mtime, + created: mtime + } + } + + /** Drop a project from memory (its cache entries persist for next load). */ + clear(rootPath?: string): void { + if (!rootPath) { + this.files.clear() + this.folders.clear() + this.mtimes.clear() + return + } + const root = stripTrailingSlash(rootPath) + const prefix = root + '/' + for (const f of [...this.files.keys()]) { + if (f === root || f.startsWith(prefix)) { + this.files.delete(f) + this.mtimes.delete(f) + } + } + for (const d of [...this.folders]) + if (d === root || d.startsWith(prefix)) this.folders.delete(d) + } +} + +export const virtualFileSystem = new VirtualFileSystemService() diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 1f3a424..c4c96d0 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -5,6 +5,7 @@ import { createRoot } from 'react-dom/client' import { Provider } from 'react-redux' import { Toaster } from 'sonner' import App from './App' +import { ErrorBoundary } from './components/ErrorBoundary' import { ThemeProvider } from './lib/ThemeProvider' import { store } from './redux' @@ -12,7 +13,9 @@ createRoot(document.getElementById('root')!).render( - + + + {/* Toast host — without this, no toast.* feedback ever renders. */} diff --git a/src/renderer/src/services/arduino/WebSocketArduinoService.ts b/src/renderer/src/services/arduino/WebSocketArduinoService.ts index 8574d2f..ea963f9 100644 --- a/src/renderer/src/services/arduino/WebSocketArduinoService.ts +++ b/src/renderer/src/services/arduino/WebSocketArduinoService.ts @@ -674,15 +674,19 @@ export class WebSocketArduinoService implements ArduinoService { // ── serial monitor (streaming, not request/response) ───────────────────────── openSerial(port: string, baud: number): void { - this.client?.serialOpen(port, baud) + if (this.client?.isConnected()) this.client.serialOpen(port, baud) } + // Guard against a dropped backend: serialClose() sends over the socket, which + // throws "WebSocket is not connected" if the backend already went away. That + // throw escaped React cleanup and blanked the whole app, so skip it when the + // socket is down — there's nothing to close anyway. closeSerial(): void { - this.client?.serialClose() + if (this.client?.isConnected()) this.client.serialClose() } writeSerial(data: string): void { - this.client?.serialWrite(data) + if (this.client?.isConnected()) this.client.serialWrite(data) } /** Subscribe to streamed serial lines. Returns an unsubscribe function. */ diff --git a/web.vite.config.mts b/web.vite.config.mts index 97e78ac..cf46aa3 100644 --- a/web.vite.config.mts +++ b/web.vite.config.mts @@ -6,7 +6,10 @@ import { defineConfig } from 'vite' export default defineConfig({ plugins: [react(), tailwindcss()], - base: './', + // Absolute base so deep SPA routes (e.g. /owner/repo/path) still resolve + // /assets/... correctly. A relative './' base would resolve assets against + // the deep path and 404 once the Netlify SPA redirect serves index.html there. + base: '/', build: { outDir: path.resolve(__dirname, 'dist-web'), emptyOutDir: true