File size: 14,753 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
// Modelo: convierte el árbol del repo en una ciudad con cajas anidadas.
// Jerarquía: ciudad → distrito → edificio → planta → apartamento → habitación
//            → archivador → cajón → fichero (los ficheros pueden vivir en
//            cualquier nivel: se les reserva un hueco en su contenedor).
// Cuando un subárbol es demasiado profundo/poblado para un solo edificio,
// se interpone una «urbanización»: una plota dentro del distrito que agrupa
// procedualmente a sus subcarpetas como edificios (recursivo), de modo que
// la profundidad sobrante se despliega por la ciudad en vez de comprimirse.
// Los ficheros ignorados (.gitignore y equivalentes) se separan en un
// sótano espejado bajo el plano de circuitos.
import { makeOwnerResolver } from './ownership.js';

export const ROLES = ['ciudad', 'distrito', 'edificio', 'planta', 'apartamento', 'habitacion', 'archivador', 'cajon'];
const ROLE_AXIS = { planta: 'y', apartamento: 'x', habitacion: 'z', archivador: 'x', cajon: 'y' };

const STREET = 14;        // separación entre distritos
const ALLEY = 4;          // separación entre edificios
const URB_PAD = 3;        // margen interior de una urbanización
// un edificio absorbe 5 niveles (planta→…→cajón); más allá, urbanización
const URB_DEPTH = 5;
const URB_MIN_FILES = 200;
const URB_MAX_FILES = 2000; // aun siendo poco profundo, esto no cabe en uno
const GROUND_Y = 0;

let nextNode = 0;
let nextFile = 0;

function hash01(str) {
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
  return ((h >>> 0) % 1000) / 1000;
}

function unpack(packed) {
  const [name, dirs, leaf] = packed;
  const node = { name, dirs: dirs.map(unpack), leaf: leaf.map(([n, s, ig]) => ({ name: n, size: s, ignored: !!ig })) };
  node.files = node.leaf.length + node.dirs.reduce((a, d) => a + d.files, 0);
  node.size = node.leaf.reduce((a, f) => a + f.size, 0) + node.dirs.reduce((a, d) => a + d.size, 0);
  return node;
}

function recount(node) {
  node.files = node.leaf.length + node.dirs.reduce((a, d) => a + d.files, 0);
  node.size = node.leaf.reduce((a, f) => a + f.size, 0) + node.dirs.reduce((a, d) => a + d.size, 0);
  return node;
}

// separa un subárbol en parte visible y parte ignorada (sótano)
function splitSrc(src) {
  const kept = { name: src.name, dirs: [], leaf: src.leaf.filter(f => !f.ignored) };
  const ign = { name: src.name, dirs: [], leaf: src.leaf.filter(f => f.ignored) };
  for (const d of src.dirs) {
    const s = splitSrc(d);
    if (s.kept.files) kept.dirs.push(s.kept);
    if (s.ign.files) ign.dirs.push(s.ign);
  }
  return { kept: recount(kept), ign: recount(ign) };
}

function makeNode(model, name, role, parent, { virtual = false } = {}) {
  const node = {
    id: nextNode++, name, role, parent, children: [], fileIds: [],
    virtual, box: null, building: null,
    path: parent ? (virtual ? parent.path : `${parent.path}/${name}`) : name,
  };
  if (parent) parent.children.push(node);
  model.nodes.push(node);
  return node;
}

function addFiles(model, node, leaf) {
  for (const f of leaf) {
    const file = {
      id: nextFile++, name: f.name, size: f.size, node,
      path: `${node.path}/${f.name}`, box: null,
    };
    node.fileIds.push(file.id);
    model.files.push(file);
  }
}

// ── dimensiones de edificio ───────────────────────────────────────────────
function buildingDims(src) {
  const kb = src.size / 1024;
  const side = Math.min(22, Math.max(6, 4 + Math.log2(1 + kb) * 1.5));
  const j = hash01(src.name);
  const w = side * (0.85 + 0.3 * j);
  const d = side * (0.85 + 0.3 * (1 - j));
  const floors = Math.max(1, src.dirs.length + (src.leaf.length ? 1 : 0));
  const h = Math.max(6, Math.max(floors * 3.0, 6 + src.files * 1.8));
  return { w, d, h: Math.min(h, 80) };
}

// ── reparto de un eje en franjas ponderadas ───────────────────────────────
function splitAxis(box, axis, weights, inset) {
  const total = weights.reduce((a, b) => a + b, 0);
  const span = box['s' + axis];
  const start = box['c' + axis] - span / 2;
  const gap = Math.min(inset, span * 0.04);
  const usable = span - gap * (weights.length + 1);
  const out = [];
  let cursor = start + gap;
  for (const w of weights) {
    const s = usable * (w / total);
    out.push({ ...box, ['c' + axis]: cursor + s / 2, ['s' + axis]: s });
    cursor += s + gap;
  }
  return out;
}

function shrink(box, f) {
  return { ...box, sx: box.sx * f, sy: box.sy * f, sz: box.sz * f };
}

// ── celdas de fichero dentro de su contenedor ─────────────────────────────
function placeFiles(model, node, box, files) {
  const n = files.length;
  if (!n) return;
  if (node.role === 'cajon') {
    // carpetas colgantes: láminas verticales en fila
    const slabs = splitAxis({ ...box, sy: box.sy * 0.72, cy: box.cy - box.sy * 0.1 }, 'x',
      files.map(() => 1), 0.05);
    files.forEach((fid, i) => {
      const s = slabs[i];
      model.files[fid].box = { ...s, sx: Math.min(s.sx * 0.55, 0.5), sz: s.sz * 0.78 };
    });
    return;
  }
  // rejilla de cubos apoyada en el suelo del contenedor
  const aspect = box.sx / box.sz || 1;
  const cols = Math.max(1, Math.round(Math.sqrt(n * aspect)));
  const rows = Math.ceil(n / cols);
  const cw = box.sx / cols, cd = box.sz / rows;
  const base = Math.min(cw, cd, box.sy) * 0.5;
  files.forEach((fid, i) => {
    const f = model.files[fid];
    const col = i % cols, row = Math.floor(i / cols);
    const k = 0.65 + 0.45 * Math.min(1, Math.log2(2 + f.size / 1024) / 8);
    const s = Math.max(0.25, base * k);
    f.box = {
      cx: box.cx - box.sx / 2 + cw * (col + 0.5),
      cz: box.cz - box.sz / 2 + cd * (row + 0.5),
      cy: box.cy - box.sy / 2 + s / 2 + 0.05,
      sx: s, sy: s, sz: s,
    };
  });
}

// ── subdivisión recursiva del interior del edificio ───────────────────────
// flipY: en los sótanos las plantas crecen hacia abajo (dirección inversa)
function layoutInterior(model, node, src, box, role, flipY = false) {
  node.box = box;
  const dirs = src.dirs;
  const hasFiles = src.leaf.length > 0;
  addFiles(model, node, src.leaf);

  if (!dirs.length) {
    placeFiles(model, node, shrink(box, 0.9), node.fileIds);
    return;
  }
  const childRole = ROLES[Math.min(ROLES.indexOf(role) + 1, ROLES.length - 1)];
  const axis = ROLE_AXIS[childRole] ?? 'y';
  const weights = dirs.map(d => 1 + Math.sqrt(d.files));
  if (hasFiles) weights.push(0.8 + Math.sqrt(src.leaf.length) * 0.7);
  const slices = splitAxis(box, axis, weights, 0.5);
  if (flipY && axis === 'y') slices.reverse();
  dirs.forEach((d, i) => {
    const child = makeNode(model, d.name, childRole, node);
    child.building = node.building;
    layoutInterior(model, child, d, shrink(slices[i], 0.94), childRole, flipY);
  });
  if (hasFiles) placeFiles(model, node, shrink(slices[slices.length - 1], 0.9), node.fileIds);
}

// ── empaquetado en rejilla (edificios en distrito, distritos en ciudad) ───
function packGrid(items, gap) {
  // items: { w, d } → coloca en filas; devuelve posiciones y tamaño del plot
  const area = items.reduce((a, it) => a + (it.w + gap) * (it.d + gap), 0);
  const targetW = Math.max(Math.sqrt(area) * 1.15, ...items.map(it => it.w + gap));
  let x = 0, z = 0, rowD = 0, plotW = 0;
  const pos = [];
  for (const it of items) {
    if (x > 0 && x + it.w > targetW) { x = 0; z += rowD + gap; rowD = 0; }
    pos.push({ x: x + it.w / 2, z: z + it.d / 2 });
    x += it.w + gap;
    rowD = Math.max(rowD, it.d);
    plotW = Math.max(plotW, x - gap);
  }
  return { pos, w: plotW, d: z + rowD };
}

function subtreeDepth(src) {
  let d = 0;
  for (const s of src.dirs) d = Math.max(d, 1 + subtreeDepth(s));
  return d;
}

// ¿demasiado profundo y poblado para un solo edificio? → urbanización
function needsUrb(src) {
  if (src.virtual || !src.dirs.length) return false;
  if (src.files > URB_MAX_FILES) return true;
  return src.files > URB_MIN_FILES && subtreeDepth(src) > URB_DEPTH;
}

function fileBlockSrc(leaf) {
  return { name: '(ficheros)', dirs: [], leaf, files: leaf.length, size: leaf.reduce((a, f) => a + f.size, 0), virtual: true };
}

// un «bloque» es lo que ocupa una parcela: un edificio o una urbanización.
// Devuelve { w, d, place(cx, cz) } para el empaquetado en rejilla.
function buildBlock(model, parent, src) {
  if (needsUrb(src)) return buildUrb(model, parent, src);
  const split = splitSrc(src);
  const b = makeNode(model, src.name, 'edificio', parent, { virtual: !!src.virtual });
  b.building = b;
  b.dims = buildingDims(split.kept.files ? split.kept : src);
  // sin nada en superficie y todo en el sótano: búnker bajo y más opaco
  // que señala que la información está bajo el suelo
  if (!split.kept.files && split.ign.files) {
    b.onlyBasement = true;
    b.dims.h = 3;
  }
  b.srcKept = split.kept;
  b.srcIgn = split.ign;
  model.buildings.push(b);
  return { w: b.dims.w, d: b.dims.d, place: (cx, cz) => placeBuilding(model, b, cx, cz) };
}

function buildUrb(model, parent, src) {
  const urb = makeNode(model, src.name, 'urbanizacion', parent);
  const childSrcs = [...src.dirs];
  if (src.leaf.length) childSrcs.push(fileBlockSrc(src.leaf));
  const blocks = childSrcs.map(s => buildBlock(model, urb, s));
  const grid = packGrid(blocks, ALLEY);
  const w = grid.w + URB_PAD * 2, d = grid.d + URB_PAD * 2;
  model.urbs.push(urb);
  return {
    w, d,
    place: (cx, cz) => {
      urb.box = { cx, cy: GROUND_Y + 0.26, cz, sx: w, sy: 0.14, sz: d };
      const ox = cx - w / 2 + URB_PAD, oz = cz - d / 2 + URB_PAD;
      blocks.forEach((blk, i) => blk.place(ox + grid.pos[i].x, oz + grid.pos[i].z));
    },
  };
}

function placeBuilding(model, b, cx, cz) {
  const { w, d, h } = b.dims;
  layoutInterior(model, b, b.srcKept, { cx, cz, cy: GROUND_Y + h / 2, sx: w, sy: h, sz: d }, 'edificio');
  // sótano: los ficheros ignorados crecen hacia abajo, mismo patrón invertido
  if (b.srcIgn.files) {
    const hb = Math.min(60, Math.max(5, Math.max(
      (b.srcIgn.dirs.length + (b.srcIgn.leaf.length ? 1 : 0)) * 3.0,
      5 + b.srcIgn.files * 1.6,
    )));
    const bb = makeNode(model, b.name, 'edificio', b.parent, { virtual: b.virtual });
    bb.building = bb;
    bb.isBasement = true;
    model.buildings.push(bb);
    layoutInterior(model, bb, b.srcIgn,
      { cx, cz, cy: GROUND_Y - 0.7 - hb / 2, sx: w, sy: hb, sz: d }, 'edificio', true);
  }
  delete b.srcKept; delete b.srcIgn;
}

function buildDistrict(model, city, srcDir, files) {
  const district = makeNode(model, srcDir ? srcDir.name : '(raíz)', 'distrito', city, { virtual: !srcDir });
  const blockSrcs = [];
  if (srcDir) {
    for (const d of srcDir.dirs) blockSrcs.push(d);
    if (srcDir.leaf.length) blockSrcs.push(fileBlockSrc(srcDir.leaf));
  } else {
    blockSrcs.push(fileBlockSrc(files));
  }
  const blocks = blockSrcs.map(s => buildBlock(model, district, s));
  const grid = packGrid(blocks, ALLEY);
  district.plot = { w: grid.w + ALLEY * 2, d: grid.d + ALLEY * 2 };
  district.blocks = blocks;
  district.grid = grid;
  model.districts.push(district);
  return district;
}

export function buildModel(packedTree, meta = null) {
  nextNode = 0; nextFile = 0;
  const model = { nodes: [], files: [], buildings: [], districts: [], urbs: [], city: null };
  const root = unpack(packedTree);
  const city = makeNode(model, root.name, 'ciudad', null);
  model.city = city;

  for (const d of root.dirs) buildDistrict(model, city, d, []);
  if (root.leaf.length) buildDistrict(model, city, null, root.leaf);

  // colocar distritos en la ciudad
  const plots = model.districts.map(di => ({ w: di.plot.w, d: di.plot.d }));
  const cityGrid = packGrid(plots, STREET);
  const ox = -cityGrid.w / 2, oz = -cityGrid.d / 2;
  model.districts.forEach((di, i) => {
    const p = cityGrid.pos[i];
    const px = ox + p.x, pz = oz + p.z;
    di.box = { cx: px, cy: GROUND_Y + 0.12, cz: pz, sx: di.plot.w, sy: 0.24, sz: di.plot.d };
    const bx = px - di.plot.w / 2 + ALLEY, bz = pz - di.plot.d / 2 + ALLEY;
    di.blocks.forEach((blk, j) => blk.place(bx + di.grid.pos[j].x, bz + di.grid.pos[j].z));
    delete di.blocks; delete di.grid;
  });
  city.box = { cx: 0, cy: 0, cz: 0, sx: cityGrid.w + STREET * 2, sy: 1, sz: cityGrid.d + STREET * 2 };

  // cadena de ancestros por fichero (para la iluminación jerárquica)
  // + metadatos git y code ownership por fichero
  const rootPrefix = city.name + '/';
  const resolveOwner = makeOwnerResolver(meta?.owners);
  const ownerIndex = new Map();
  for (const f of model.files) {
    const chain = [];
    for (let n = f.node; n; n = n.parent) chain.push(n);
    f.chain = chain; // del contenedor inmediato hacia la ciudad
    f.rel = f.path.startsWith(rootPrefix) ? f.path.slice(rootPrefix.length) : f.path;
    const g = meta?.git?.[f.rel];
    f.git = g ? { st: g[0], a: g[1], d: g[2] } : null;
    const owner = resolveOwner(f.rel);
    if (owner != null) {
      if (!ownerIndex.has(owner)) ownerIndex.set(owner, ownerIndex.size);
      f.owner = ownerIndex.get(owner);
    } else {
      f.owner = null;
    }
  }
  model.owners = [...ownerIndex.keys()];
  model.hasGit = !!meta?.git;

  applyCodeMeta(model, meta?.links, meta?.codeIndex);
  model.fileByRel = new Map(model.files.map(f => [f.rel, f]));
  model.dirByRel = new Map();
  for (const n of model.nodes) {
    if (n.role === 'ciudad' || n.virtual || n.isBasement || n.building?.isBasement) continue;
    const rel = n.path.startsWith(rootPrefix) ? n.path.slice(rootPrefix.length) : n.path;
    if (!model.dirByRel.has(rel)) model.dirByRel.set(rel, n);
  }
  return model;
}

// enlaces de código (modo Blast): adyacencia saliente y entrante. Es una
// función aparte porque el análisis perezoso los refresca por lotes sobre
// el modelo ya construido, sin reconstruir la ciudad.
export function applyCodeMeta(model, links, codeIndex) {
  model.linksOut = links ?? {};
  model.linksIn = {};
  for (const [src, toks] of Object.entries(model.linksOut)) {
    for (const tok of toks) {
      if (tok.startsWith('F:')) (model.linksIn[tok.slice(2)] ??= []).push(src);
    }
  }
  // índice de símbolos (funciones/clases/tipos): aristas "src usa sym de target"
  model.defs = codeIndex?.defs ?? {};
  model.symOut = codeIndex?.symLinks ?? {};
  model.symIn = {};
  for (const [src, edges] of Object.entries(model.symOut)) {
    for (const [target, sym] of edges) (model.symIn[target] ??= []).push([src, sym]);
  }
}