honeytree 1.1.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ import { ANIMATION_KEYFRAMES } from "./animation-keyframes.js";
2
+ import { getSprite } from "./sprites.js";
3
+
4
+ const CHAR_ORDER = [" ", "░", "▒", "█"];
5
+
6
+ function charIndex(ch) {
7
+ const idx = CHAR_ORDER.indexOf(ch);
8
+ return idx >= 0 ? idx : 0;
9
+ }
10
+
11
+ function interpolateChar(ch1, ch2, t) {
12
+ const i1 = charIndex(ch1);
13
+ const i2 = charIndex(ch2);
14
+ const idx = Math.round(i1 + (i2 - i1) * t);
15
+ return CHAR_ORDER[Math.max(0, Math.min(CHAR_ORDER.length - 1, idx))];
16
+ }
17
+
18
+ function parseHex(hex) {
19
+ const h = hex.startsWith("#") ? hex.slice(1) : hex;
20
+ return {
21
+ r: parseInt(h.slice(0, 2), 16),
22
+ g: parseInt(h.slice(2, 4), 16),
23
+ b: parseInt(h.slice(4, 6), 16),
24
+ };
25
+ }
26
+
27
+ function toHex({ r, g, b }) {
28
+ const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
29
+ return `#${c(r)}${c(g)}${c(b)}`;
30
+ }
31
+
32
+ function lerpColor(hex1, hex2, t) {
33
+ const c1 = parseHex(hex1);
34
+ const c2 = parseHex(hex2);
35
+ return toHex({
36
+ r: c1.r + (c2.r - c1.r) * t,
37
+ g: c1.g + (c2.g - c1.g) * t,
38
+ b: c1.b + (c2.b - c1.b) * t,
39
+ });
40
+ }
41
+
42
+ function interpolateSprite(sprite1, sprite2, t) {
43
+ const rows = sprite1.rows.map((row, r) =>
44
+ row.map(([ch1, col1], c) => {
45
+ const [ch2, col2] = sprite2.rows[r][c];
46
+ if (!col1 && !col2) return [" ", null];
47
+ if (!col1 && col2) {
48
+ const ch = interpolateChar(" ", ch2, t);
49
+ if (ch === " ") return [" ", null];
50
+ return [ch, col2];
51
+ }
52
+ if (col1 && !col2) {
53
+ const ch = interpolateChar(ch1, " ", t);
54
+ if (ch === " ") return [" ", null];
55
+ return [ch, col1];
56
+ }
57
+ const ch = interpolateChar(ch1, ch2, t);
58
+ const col = lerpColor(col1, col2, t);
59
+ return [ch, col];
60
+ }),
61
+ );
62
+ return { rows, width: sprite1.width };
63
+ }
64
+
65
+ function brightenSprite(sprite, amount) {
66
+ const rows = sprite.rows.map((row) =>
67
+ row.map(([ch, col]) => {
68
+ if (!col) return [ch, col];
69
+ const c = parseHex(col);
70
+ return [ch, toHex({
71
+ r: Math.min(255, c.r + (255 - c.r) * amount),
72
+ g: Math.min(255, c.g + (255 - c.g) * amount),
73
+ b: Math.min(255, c.b + (255 - c.b) * amount),
74
+ })];
75
+ }),
76
+ );
77
+ return { rows, width: sprite.width };
78
+ }
79
+
80
+ export function getAnimationFrames(type, growth, frameCount = 40) {
81
+ const allKeyframes = ANIMATION_KEYFRAMES[type];
82
+ if (!allKeyframes) throw new Error(`No animation keyframes for type: ${type}`);
83
+
84
+ const finalSprite = getSprite(type, growth);
85
+
86
+ let keyframes;
87
+ if (growth >= 0.8) {
88
+ keyframes = allKeyframes;
89
+ } else {
90
+ let maxKF;
91
+ if (growth < 0.2) maxKF = 3;
92
+ else if (growth < 0.5) maxKF = 6;
93
+ else maxKF = 8;
94
+
95
+ keyframes = allKeyframes.slice(0, maxKF);
96
+ const lastTime = keyframes[keyframes.length - 1].time;
97
+ const timeScale = 4.0 / Math.max(lastTime, 0.1);
98
+
99
+ keyframes = keyframes.map((kf) => ({ ...kf, time: kf.time * timeScale }));
100
+ keyframes.push({ time: 4.0, sprite: finalSprite });
101
+ keyframes.push({ time: 4.5, sprite: brightenSprite(finalSprite, 0.2) });
102
+ }
103
+
104
+ const totalDuration = 5.0;
105
+ const frames = [];
106
+
107
+ for (let i = 0; i < frameCount; i++) {
108
+ const t = (i / (frameCount - 1)) * totalDuration;
109
+
110
+ let kfBefore = keyframes[0];
111
+ let kfAfter = keyframes[0];
112
+
113
+ for (let k = 0; k < keyframes.length - 1; k++) {
114
+ if (t >= keyframes[k].time && t <= keyframes[k + 1].time) {
115
+ kfBefore = keyframes[k];
116
+ kfAfter = keyframes[k + 1];
117
+ break;
118
+ }
119
+ if (k === keyframes.length - 2) {
120
+ kfBefore = keyframes[keyframes.length - 1];
121
+ kfAfter = keyframes[keyframes.length - 1];
122
+ }
123
+ }
124
+
125
+ const segmentDuration = kfAfter.time - kfBefore.time;
126
+ const segmentT = segmentDuration > 0 ? (t - kfBefore.time) / segmentDuration : 1;
127
+ const clampedT = Math.max(0, Math.min(1, segmentT));
128
+
129
+ const sprite = interpolateSprite(kfBefore.sprite, kfAfter.sprite, clampedT);
130
+
131
+ let groundOverlay = null;
132
+ if (t < 0.5 && kfBefore.groundEffect) {
133
+ const ge = kfBefore.groundEffect;
134
+ groundOverlay = [];
135
+ for (let dx = -ge.radius; dx <= ge.radius; dx++) {
136
+ const intensity = 1 - Math.abs(dx) / ge.radius;
137
+ if (intensity > 0.3) {
138
+ groundOverlay.push({ dx, char: "░", color: ge.color });
139
+ }
140
+ }
141
+ }
142
+
143
+ if (i === frameCount - 1) {
144
+ frames.push({ sprite: finalSprite, groundOverlay: null, groundPulse: false });
145
+ } else {
146
+ frames.push({ sprite, groundOverlay, groundPulse: i < 3 });
147
+ }
148
+ }
149
+
150
+ return frames;
151
+ }
package/src/camera.js ADDED
@@ -0,0 +1,54 @@
1
+ // src/camera.js
2
+
3
+ const DEG_TO_RAD = Math.PI / 180;
4
+
5
+ export function createCamera() {
6
+ return {
7
+ azimuth: 45,
8
+ elevation: 30,
9
+ distance: 60,
10
+ };
11
+ }
12
+
13
+ export function rotatePoint(x, y, z, azimuthDeg, elevationDeg) {
14
+ const az = azimuthDeg * DEG_TO_RAD;
15
+ const el = elevationDeg * DEG_TO_RAD;
16
+
17
+ const cosAz = Math.cos(az);
18
+ const sinAz = Math.sin(az);
19
+ const x1 = x * cosAz + z * sinAz;
20
+ const z1 = -x * sinAz + z * cosAz;
21
+
22
+ const cosEl = Math.cos(el);
23
+ const sinEl = Math.sin(el);
24
+ const y1 = y * cosEl - z1 * sinEl;
25
+ const z2 = y * sinEl + z1 * cosEl;
26
+
27
+ return [x1, y1, z2];
28
+ }
29
+
30
+ export function projectPoint(x, y, z, screenWidth, screenHeight, distance = 25) {
31
+ const fov = 60;
32
+ const fovRad = fov * DEG_TO_RAD;
33
+ const focalLength = screenHeight / (2 * Math.tan(fovRad / 2));
34
+
35
+ const zView = z - distance;
36
+
37
+ if (zView >= -1) {
38
+ return { screenX: -1, screenY: -1, depth: Infinity, visible: false };
39
+ }
40
+
41
+ const scale = focalLength / -zView;
42
+ const screenX = Math.round(x * scale * 2 + screenWidth / 2);
43
+ const screenY = Math.round(-y * scale + screenHeight / 2);
44
+
45
+ return { screenX, screenY, depth: -zView, visible: true };
46
+ }
47
+
48
+ export function clampElevation(elevation) {
49
+ return Math.max(10, Math.min(80, elevation));
50
+ }
51
+
52
+ export function clampAzimuth(azimuth) {
53
+ return ((azimuth % 360) + 360) % 360;
54
+ }
@@ -0,0 +1,32 @@
1
+ import { execSync } from "node:child_process";
2
+ import { hunkToPatch } from "./diffparser.js";
3
+
4
+ export function stageHunk(rootDir, filePath, hunk) {
5
+ try {
6
+ const patch = hunkToPatch(filePath, hunk);
7
+ execSync("git apply --cached --unidiff-zero -", {
8
+ cwd: rootDir,
9
+ input: patch,
10
+ timeout: 5000,
11
+ stdio: ["pipe", "pipe", "pipe"],
12
+ });
13
+ return { ok: true };
14
+ } catch (err) {
15
+ return { ok: false, error: err.message };
16
+ }
17
+ }
18
+
19
+ export function revertHunk(rootDir, filePath, hunk) {
20
+ try {
21
+ const patch = hunkToPatch(filePath, hunk);
22
+ execSync("git apply --reverse --unidiff-zero -", {
23
+ cwd: rootDir,
24
+ input: patch,
25
+ timeout: 5000,
26
+ stdio: ["pipe", "pipe", "pipe"],
27
+ });
28
+ return { ok: true };
29
+ } catch (err) {
30
+ return { ok: false, error: err.message };
31
+ }
32
+ }
@@ -0,0 +1,83 @@
1
+ // src/diffpanel.js
2
+ import chalk from "chalk";
3
+
4
+ chalk.level = 3;
5
+
6
+ export function createDiffPanel(filePath, hunks) {
7
+ return {
8
+ filePath,
9
+ hunks,
10
+ currentHunk: 0,
11
+ hunkStatus: hunks.map(() => "pending"),
12
+ scrollOffset: 0,
13
+ };
14
+ }
15
+
16
+ export function renderDiffPanel(panel, width, height) {
17
+ const lines = [];
18
+ const totalAdded = panel.hunks.reduce((s, h) => s + h.added, 0);
19
+ const totalRemoved = panel.hunks.reduce((s, h) => s + h.removed, 0);
20
+
21
+ // Header
22
+ const header = ` ${panel.filePath} +${totalAdded} -${totalRemoved} ${panel.hunks.length} hunks`;
23
+ lines.push(chalk.hex("#f5a50b").bold(header.padEnd(width).slice(0, width)));
24
+
25
+ // Separator
26
+ lines.push(chalk.hex("#555555")("─".repeat(width)));
27
+
28
+ // Help line
29
+ const help = " j/k: navigate a: accept r: reject A/R: all Esc: close";
30
+ lines.push(chalk.hex("#888888")(help.padEnd(width).slice(0, width)));
31
+
32
+ lines.push(chalk.hex("#555555")("─".repeat(width)));
33
+
34
+ // Render hunks
35
+ const bodyHeight = height - lines.length;
36
+ const bodyLines = [];
37
+
38
+ for (let hi = 0; hi < panel.hunks.length; hi++) {
39
+ const hunk = panel.hunks[hi];
40
+ const isCurrent = hi === panel.currentHunk;
41
+ const status = panel.hunkStatus[hi];
42
+
43
+ // Hunk header with status
44
+ let statusIcon = "[ ]";
45
+ if (status === "accepted") statusIcon = chalk.green("[✓]");
46
+ else if (status === "rejected") statusIcon = chalk.red("[✗]");
47
+
48
+ const hunkHeader = isCurrent
49
+ ? chalk.hex("#ffcc44").bold(`▸ Hunk ${hi + 1}/${panel.hunks.length} ${statusIcon} ${hunk.header}`)
50
+ : chalk.hex("#888888")(` Hunk ${hi + 1}/${panel.hunks.length} ${statusIcon} ${hunk.header}`);
51
+
52
+ bodyLines.push(hunkHeader.padEnd(width).slice(0, width));
53
+
54
+ // Hunk lines
55
+ for (const line of hunk.lines) {
56
+ let rendered;
57
+ const text = line.text.padEnd(width).slice(0, width);
58
+ if (line.type === "added") {
59
+ rendered = chalk.green(text);
60
+ } else if (line.type === "removed") {
61
+ rendered = chalk.red(text);
62
+ } else {
63
+ rendered = isCurrent ? chalk.hex("#cccccc")(text) : chalk.hex("#666666")(text);
64
+ }
65
+ bodyLines.push(rendered);
66
+ }
67
+
68
+ bodyLines.push(""); // blank line between hunks
69
+ }
70
+
71
+ // Apply scroll offset and fill to height
72
+ const scrolled = bodyLines.slice(panel.scrollOffset, panel.scrollOffset + bodyHeight);
73
+ for (const line of scrolled) {
74
+ lines.push(line);
75
+ }
76
+
77
+ // Pad remaining height
78
+ while (lines.length < height) {
79
+ lines.push(" ".repeat(width));
80
+ }
81
+
82
+ return lines.slice(0, height);
83
+ }
@@ -0,0 +1,44 @@
1
+ export function parseDiff(diffText) {
2
+ if (!diffText || !diffText.trim()) return [];
3
+
4
+ const lines = diffText.split("\n");
5
+ const hunks = [];
6
+ let currentHunk = null;
7
+
8
+ for (const line of lines) {
9
+ if (line.startsWith("@@")) {
10
+ if (currentHunk) {
11
+ currentHunk.added = currentHunk.lines.filter(l => l.type === "added").length;
12
+ currentHunk.removed = currentHunk.lines.filter(l => l.type === "removed").length;
13
+ hunks.push(currentHunk);
14
+ }
15
+ currentHunk = { header: line, lines: [], added: 0, removed: 0 };
16
+ } else if (currentHunk) {
17
+ if (line.startsWith("+")) {
18
+ currentHunk.lines.push({ type: "added", text: line });
19
+ } else if (line.startsWith("-")) {
20
+ currentHunk.lines.push({ type: "removed", text: line });
21
+ } else if (line.startsWith(" ") || line === "") {
22
+ currentHunk.lines.push({ type: "context", text: line });
23
+ }
24
+ }
25
+ }
26
+
27
+ if (currentHunk) {
28
+ currentHunk.added = currentHunk.lines.filter(l => l.type === "added").length;
29
+ currentHunk.removed = currentHunk.lines.filter(l => l.type === "removed").length;
30
+ hunks.push(currentHunk);
31
+ }
32
+
33
+ return hunks;
34
+ }
35
+
36
+ export function hunkToPatch(filePath, hunk) {
37
+ const lines = [
38
+ `--- a/${filePath}`,
39
+ `+++ b/${filePath}`,
40
+ hunk.header,
41
+ ...hunk.lines.map(l => l.text),
42
+ ];
43
+ return lines.join("\n") + "\n";
44
+ }
@@ -0,0 +1,52 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ function execAsync(cmd, args, opts) {
6
+ return new Promise((resolve) => {
7
+ execFile(cmd, args, { timeout: 5000, encoding: "utf-8", ...opts }, (err, stdout) => {
8
+ resolve(err ? "" : stdout);
9
+ });
10
+ });
11
+ }
12
+
13
+ export async function getChangedFiles(rootDir) {
14
+ const changed = new Set();
15
+
16
+ const [tracked, untracked] = await Promise.all([
17
+ execAsync("git", ["diff", "--name-only"], { cwd: rootDir }),
18
+ execAsync("git", ["ls-files", "--others", "--exclude-standard"], { cwd: rootDir }),
19
+ ]);
20
+
21
+ for (const output of [tracked, untracked]) {
22
+ for (const line of output.split("\n")) {
23
+ const trimmed = line.trim();
24
+ if (trimmed) changed.add(trimmed);
25
+ }
26
+ }
27
+
28
+ return changed;
29
+ }
30
+
31
+ export function getFileDiff(rootDir, filePath) {
32
+ return execAsync("git", ["diff", "-U3", "--", filePath], { cwd: rootDir });
33
+ }
34
+
35
+ export function watchForChanges(rootDir, callback) {
36
+ let debounceTimer = null;
37
+ const debounced = () => {
38
+ if (debounceTimer) clearTimeout(debounceTimer);
39
+ debounceTimer = setTimeout(callback, 300);
40
+ };
41
+
42
+ try {
43
+ const watcher = fs.watch(rootDir, { recursive: true }, (event, filename) => {
44
+ if (!filename) return;
45
+ if (filename.includes("node_modules") || filename.includes(".git")) return;
46
+ debounced();
47
+ });
48
+ return watcher;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
@@ -0,0 +1,193 @@
1
+ const SPECIES = {
2
+ oak: { name: "oak", colors: ["#55cc44", "#44bb33", "#66dd55", "#77ee66", "#3da832"], shape: "ellipsoid", widthScale: 1.4, heightScale: 1.0 },
3
+ pine: { name: "pine", colors: ["#33bbaa", "#22aa99", "#44ccbb", "#55ddcc", "#11997a"], shape: "cone", widthScale: 0.7, heightScale: 1.6 },
4
+ birch: { name: "birch", colors: ["#ff77cc", "#ee55bb", "#ff99dd", "#ffaaee", "#dd44aa"], shape: "ellipsoid", widthScale: 0.8, heightScale: 1.0 },
5
+ willow: { name: "willow", colors: ["#aadd44", "#99cc33", "#bbee55", "#ccff66", "#88bb22"], shape: "drooping", widthScale: 1.2, heightScale: 1.0 },
6
+ cherry: { name: "cherry", colors: ["#ff88cc", "#ff66bb", "#ffaadd", "#ffbbee", "#ee55aa", "#ff99ff", "#dd77cc"], shape: "sphere", widthScale: 1.0, heightScale: 1.0 },
7
+ default: { name: "default", colors: ["#88bb88", "#77aa77", "#99cc99", "#aaddaa"], shape: "ellipsoid", widthScale: 1.2, heightScale: 1.0 },
8
+ };
9
+
10
+ const TRUNK_COLOR = "#8B6914";
11
+ const AMBER_COLORS = ["#ff3333", "#ff5555", "#ff1111", "#ff4444", "#ffffff", "#ffcccc"];
12
+
13
+ const EXT_MAP = {
14
+ ".js": "oak", ".jsx": "oak", ".mjs": "oak", ".cjs": "oak",
15
+ ".ts": "pine", ".tsx": "pine", ".mts": "pine",
16
+ ".css": "birch", ".scss": "birch", ".sass": "birch", ".less": "birch",
17
+ ".py": "willow", ".pyw": "willow",
18
+ ".md": "cherry", ".json": "cherry", ".yaml": "cherry", ".yml": "cherry",
19
+ ".toml": "cherry", ".xml": "cherry", ".ini": "cherry",
20
+ };
21
+
22
+ export function getSpecies(extension) {
23
+ const key = EXT_MAP[extension] || "default";
24
+ return SPECIES[key];
25
+ }
26
+
27
+ function seededRandom(seed) {
28
+ let s = seed >>> 0;
29
+ return () => {
30
+ s = Math.imul((s >>> 16) ^ s, 0x45d9f3b) >>> 0;
31
+ s = Math.imul((s >>> 16) ^ s, 0x45d9f3b) >>> 0;
32
+ s = ((s >>> 16) ^ s) >>> 0;
33
+ return s / 0x100000000;
34
+ };
35
+ }
36
+
37
+ function hashString(str) {
38
+ let h = 0;
39
+ for (let i = 0; i < str.length; i++) {
40
+ h = Math.imul(31, h) + str.charCodeAt(i) | 0;
41
+ }
42
+ return h >>> 0;
43
+ }
44
+
45
+ export function generateTreeCloud(file, position, fileIndex = 0, lodScale = 1) {
46
+ const species = getSpecies(file.extension);
47
+ const colors = file.changed ? AMBER_COLORS : species.colors;
48
+ const seed = hashString(file.relativePath);
49
+ const rng = seededRandom(seed);
50
+
51
+ const sizeLog = Math.log2(Math.max(1, file.size));
52
+ const changedBoost = file.changed ? 1.5 : 1;
53
+ const basePoints = Math.round((220 + sizeLog * 35) * changedBoost);
54
+ const churnMultiplier = 1 + Math.min(1, (file.churn || 0) / 30);
55
+ const canopyCount = Math.round(basePoints * churnMultiplier * lodScale);
56
+
57
+ const height = 5 + sizeLog * 1.1;
58
+ const canopyCenterY = height;
59
+ const canopyRadiusX = (height * 0.45) * species.widthScale * changedBoost;
60
+ const canopyRadiusY = (height * 0.5) * species.heightScale;
61
+ const canopyRadiusZ = canopyRadiusX;
62
+
63
+ const points = [];
64
+
65
+ for (let i = 0; i < canopyCount; i++) {
66
+ let px, py, pz;
67
+
68
+ if (species.shape === "cone") {
69
+ const t = rng();
70
+ const angle = rng() * Math.PI * 2;
71
+ const radius = t * canopyRadiusX;
72
+ px = Math.cos(angle) * radius;
73
+ pz = Math.sin(angle) * radius;
74
+ py = canopyCenterY + canopyRadiusY * (1 - t);
75
+ } else if (species.shape === "drooping") {
76
+ const u = rng() * Math.PI * 2;
77
+ const v = rng() * Math.PI;
78
+ const r = rng();
79
+ px = Math.cos(u) * Math.sin(v) * canopyRadiusX * r;
80
+ py = canopyCenterY + Math.cos(v) * canopyRadiusY * r;
81
+ pz = Math.sin(u) * Math.sin(v) * canopyRadiusZ * r;
82
+ if (rng() < 0.3) {
83
+ py = canopyCenterY - rng() * canopyRadiusY * 0.8;
84
+ }
85
+ } else if (species.shape === "sphere") {
86
+ const u = rng() * Math.PI * 2;
87
+ const v = Math.acos(2 * rng() - 1);
88
+ const r = Math.cbrt(rng()) * canopyRadiusX;
89
+ px = Math.cos(u) * Math.sin(v) * r;
90
+ py = canopyCenterY + Math.cos(v) * r;
91
+ pz = Math.sin(u) * Math.sin(v) * r;
92
+ } else {
93
+ const u = rng() * Math.PI * 2;
94
+ const v = Math.acos(2 * rng() - 1);
95
+ const r = Math.cbrt(rng());
96
+ px = Math.cos(u) * Math.sin(v) * canopyRadiusX * r;
97
+ py = canopyCenterY + Math.cos(v) * canopyRadiusY * r;
98
+ pz = Math.sin(u) * Math.sin(v) * canopyRadiusZ * r;
99
+ }
100
+
101
+ const color = colors[Math.floor(rng() * colors.length)];
102
+ points.push({
103
+ x: position.x + px,
104
+ y: py,
105
+ z: position.z + pz,
106
+ color,
107
+ fileIndex,
108
+ });
109
+ }
110
+
111
+ const trunkCount = Math.round((8 + height * 2) * Math.max(0.5, lodScale));
112
+ for (let i = 0; i < trunkCount; i++) {
113
+ const t = i / trunkCount;
114
+ points.push({
115
+ x: position.x + (rng() - 0.5) * 0.5,
116
+ y: t * (canopyCenterY - canopyRadiusY * 0.5),
117
+ z: position.z + (rng() - 0.5) * 0.5,
118
+ color: TRUNK_COLOR,
119
+ fileIndex,
120
+ });
121
+ }
122
+
123
+ return points;
124
+ }
125
+
126
+ export function generateForestCloud(files) {
127
+ const dirGroups = {};
128
+ for (let i = 0; i < files.length; i++) {
129
+ const dir = files[i].directory || ".";
130
+ const topDir = dir === "." ? "." : dir.split("/")[0];
131
+ if (!dirGroups[topDir]) dirGroups[topDir] = [];
132
+ dirGroups[topDir].push({ file: files[i], index: i });
133
+ }
134
+
135
+ const dirs = Object.keys(dirGroups);
136
+ const totalFiles = files.length;
137
+ const spreadRadius = Math.max(20, Math.sqrt(totalFiles) * 8);
138
+
139
+ const MAX_POINTS = 80000;
140
+ const estimatedPointsPerFile = 400;
141
+ const estimatedTotal = totalFiles * estimatedPointsPerFile;
142
+ const lodScale = estimatedTotal > MAX_POINTS ? MAX_POINTS / estimatedTotal : 1;
143
+
144
+ const allPoints = [];
145
+ const filePaths = files.map((f) => f.relativePath);
146
+
147
+ dirs.forEach((dir, dirIndex) => {
148
+ const angle = (dirIndex / dirs.length) * Math.PI * 2;
149
+ const clusterCenterX = Math.cos(angle) * spreadRadius * 0.6;
150
+ const clusterCenterZ = Math.sin(angle) * spreadRadius * 0.6;
151
+
152
+ const group = dirGroups[dir];
153
+ const clusterSpread = Math.max(8, Math.sqrt(group.length) * 6);
154
+
155
+ group.forEach((entry, fileInGroup) => {
156
+ const seed = hashString(entry.file.relativePath);
157
+ const rng = seededRandom(seed);
158
+ const fx = clusterCenterX + (rng() - 0.5) * clusterSpread;
159
+ const fz = clusterCenterZ + (rng() - 0.5) * clusterSpread;
160
+
161
+ const treePoints = generateTreeCloud(entry.file, { x: fx, z: fz }, entry.index, lodScale);
162
+ allPoints.push(...treePoints);
163
+ });
164
+ });
165
+
166
+ return { points: allPoints, filePaths };
167
+ }
168
+
169
+ export function generateGroundPlane(radius) {
170
+ const points = [];
171
+ const step = 0.7;
172
+ const groundColors = ["#3a2a1a", "#4a3a2a", "#352515", "#2a1a0a"];
173
+
174
+ for (let x = -radius; x <= radius; x += step) {
175
+ for (let z = -radius; z <= radius; z += step) {
176
+ if (x * x + z * z > radius * radius) continue;
177
+
178
+ const seed = hashString(`ground_${x}_${z}`);
179
+ const rng = seededRandom(seed);
180
+ const color = groundColors[Math.floor(rng() * groundColors.length)];
181
+
182
+ points.push({
183
+ x,
184
+ y: 0,
185
+ z,
186
+ color,
187
+ fileIndex: -1,
188
+ });
189
+ }
190
+ }
191
+
192
+ return points;
193
+ }
package/src/renderer.js CHANGED
@@ -160,6 +160,14 @@ function getTreeYOffset(treeId) {
160
160
  return h % 2; // Returns 0 or 1 (only up, never below ground)
161
161
  }
162
162
 
163
+ function getWindOffset(treeId, windTick) {
164
+ if (windTick == null) return 0;
165
+ const windDirection = Math.floor(windTick / 4) % 2 === 0 ? 1 : -1;
166
+ const treePhase = hash(treeId * 7) % 3;
167
+ const effectiveTick = Math.max(0, windTick - treePhase);
168
+ return (effectiveTick % 2 === 0) ? 0 : windDirection;
169
+ }
170
+
163
171
  function generateStars(width, biome, twinkle = 0) {
164
172
  const stars = [];
165
173
  for (let x = 0; x < width; x += 1) {
@@ -175,14 +183,16 @@ function generateStars(width, biome, twinkle = 0) {
175
183
  return stars;
176
184
  }
177
185
 
178
- function compositeSprite(buffer, sprite, centerX, baseY) {
186
+ function compositeSprite(buffer, sprite, centerX, baseY, canopyShiftX = 0) {
179
187
  const offsetX = centerX - Math.floor(sprite.width / 2);
188
+ const trunkRows = 2;
180
189
  for (let rowIndex = 0; rowIndex < sprite.rows.length; rowIndex += 1) {
181
190
  const targetY = baseY - rowIndex;
182
191
  if (targetY < 0 || targetY >= buffer.length) continue;
183
192
  const row = sprite.rows[rowIndex];
193
+ const shiftX = rowIndex < trunkRows ? 0 : canopyShiftX;
184
194
  for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
185
- const targetX = offsetX + columnIndex;
195
+ const targetX = offsetX + columnIndex + shiftX;
186
196
  if (targetX < 0 || targetX >= buffer[0].length) continue;
187
197
  const [char, color] = row[columnIndex];
188
198
  if (!color) continue;
@@ -320,10 +330,46 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
320
330
  }
321
331
  }
322
332
 
333
+ const windTick = options.windTick ?? null;
323
334
  const treeBaseY = groundStart - 1;
335
+ const spriteOverride = options.spriteOverride ?? null;
324
336
  for (const tree of forest.trees) {
325
337
  const yOffset = getTreeYOffset(tree.id);
326
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
338
+ const sprite = (spriteOverride && spriteOverride.treeId === tree.id)
339
+ ? spriteOverride.sprite
340
+ : getSprite(tree.type, tree.growth);
341
+ const canopyShiftX = getWindOffset(tree.id, windTick);
342
+ compositeSprite(buffer, sprite, tree.x, treeBaseY - yOffset, canopyShiftX);
343
+ }
344
+
345
+ // Apply ground overlay (soil reaction during tree birth animation)
346
+ const groundOverlayOpt = options.groundOverlay ?? null;
347
+ if (groundOverlayOpt && groundOverlayOpt.overlays) {
348
+ const groundRow = groundStart; // first ground row
349
+ for (const overlay of groundOverlayOpt.overlays) {
350
+ const ox = groundOverlayOpt.treeX + overlay.dx;
351
+ if (ox >= 0 && ox < virtualWidth && groundRow < buffer.length) {
352
+ buffer[groundRow][ox] = { char: overlay.char, color: overlay.color };
353
+ }
354
+ }
355
+ }
356
+
357
+ // Ground pulse — brighten all ground pixels by 30% during planting flash
358
+ const groundPulse = options.groundPulse ?? false;
359
+ if (groundPulse) {
360
+ for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
361
+ for (let x = 0; x < virtualWidth; x += 1) {
362
+ const cell = buffer[groundStart + rowIndex][x];
363
+ if (cell.color) {
364
+ const c = parseHex(cell.color);
365
+ cell.color = toHex({
366
+ r: Math.min(255, c.r + (255 - c.r) * 0.3),
367
+ g: Math.min(255, c.g + (255 - c.g) * 0.3),
368
+ b: Math.min(255, c.b + (255 - c.b) * 0.3),
369
+ });
370
+ }
371
+ }
372
+ }
327
373
  }
328
374
 
329
375
  renderGroundDetails(buffer, biome, virtualWidth, groundStart);