PageSourceSearch

https://opengear.com/wp-content/plugins/opengear-configurator/public/js/diagram.js?ver=1.5

js opengear.com collected 2026-10-02 17:35:51 UTC 22,842 bytes, 464 lines download raw bytes

1// ── NETWORK DIAGRAM GENERATOR ──────────────────────────────────────────────
2
3function showDiagram() {
4  var wrap = document.getElementById('diagramSvgWrap');
5  wrap.innerHTML = buildDiagramSVG();
6  document.getElementById('diagramModal').removeAttribute('hidden');
7}
8
9function closeDiagram() {
10  document.getElementById('diagramModal').setAttribute('hidden', '');
11}
12
13function downloadDiagramSVG() {
14  var el = document.querySelector('#diagramSvgWrap svg');
15  if (!el) return;
16  var xml  = '<?xml version="1.0" encoding="UTF-8"?>\n' + el.outerHTML;
17  var blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
18  var url  = URL.createObjectURL(blob);
19  var a    = document.createElement('a');
20  a.href = url; a.download = 'opengear-diagram.svg';
21  document.body.appendChild(a); a.click();
22  document.body.removeChild(a); URL.revokeObjectURL(url);
23}
24
25function downloadDiagramPNG() {
26  var svgEl = document.querySelector('#diagramSvgWrap svg');
27  if (!svgEl) return;
28  var W = parseInt(svgEl.getAttribute('width'));
29  var H = parseInt(svgEl.getAttribute('height'));
30  var canvas = document.createElement('canvas');
31  canvas.width  = W * 2;
32  canvas.height = H * 2;
33  var ctx = canvas.getContext('2d');
34  ctx.scale(2, 2);
35  var xml  = '<?xml version="1.0" encoding="UTF-8"?>' + svgEl.outerHTML;
36  var blob = new Blob([xml], { type: 'image/svg+xml' });
37  var url  = URL.createObjectURL(blob);
38  var img  = new Image();
39  img.onload = function() {
40    ctx.drawImage(img, 0, 0);
41    URL.revokeObjectURL(url);
42    var a = document.createElement('a');
43    a.href = canvas.toDataURL('image/png');
44    a.download = 'opengear-diagram.png';
45    document.body.appendChild(a); a.click(); document.body.removeChild(a);
46  };
47  img.src = url;
48}
49
50function buildDiagramSVG() {
51  if (state.deploymentMode && state.configs && state.configs.some(function(s) { return s.racks && s.racks.length > 0; })) {
52    return _dgDeploy();
53  }
54  return _dgSingle();
55}
56
57// ── Colors ─────────────────────────────────────────────────────────────────
58var _DG = {
59  bg:         '#f8fafc',
60  titleBg:    '#0f172a',
61  device:     '#3a3a3a', deviceStroke: '#505050', deviceBezel: '#525252',
62  led:        '#33ee55', ledGlow: '#22cc44',
63  ports:      '#22c55e',
64  rack:       '#3a3a3a', rackBezel: '#525252', rackHighlight: '#585858',
65  rackFrame:  '#c8cdd4', rackFrameStroke: '#8a9199', rackFrameInner: '#d8dce2',
66  lh:         '#3b0764', lhStroke: '#7c3aed',
67  site:       '#e8ecf0', siteStroke: '#8a9199', siteHdr: '#c8cdd4',
68  acc:        '#1e293b', accStroke: '#334155',
69  line:       '#94a3b8',
70  badge:      '#0ea5e9',
71  support:    '#7c3aed',
72  text:       '#0f172a', textMid: '#475569', textMut: '#94a3b8',
73};
74
75// ── SINGLE DEVICE DIAGRAM ──────────────────────────────────────────────────
76function _dgSingle() {
77  var model   = state.bomModel;
78  var hasLH   = state.lighthouse === 'adding';
79  var lhSku   = hasLH ? getLighthouseSku() : null;
80
81  var accList = Object.keys(state.accAdded || {}).map(function(sku) {
82    var acc = ALL_ACCESSORIES.find(function(a) { return a.sku === sku; });
83    return acc ? { title: acc.title, sku: sku, qty: state.accAdded[sku].qty || 1 } : null;
84  }).filter(Boolean);
85
86  var W = 780, PAD = 40, cx = W / 2;
87  var DW = 290, DH = 72;
88  var LHW = 260, LHH = 72;
89  var AW = 168, AH = 50, AGAP = 12, ACOLS = 4;
90
91  // Y layout
92  var y = 72;
93  var lhTop = 0, lhBtm = 0;
94  if (hasLH) { lhTop = y; lhBtm = lhTop + LHH; y = lhBtm + 44; }
95  var devTop = y, devBtm = devTop + DH;
96  y = devBtm;
97
98  var showAccs = accList.slice(0, 8);
99  var accCols  = Math.min(ACOLS, showAccs.length);
100  var accRows  = Math.ceil(showAccs.length / ACOLS);
101  var busY = 0, accTop = 0;
102  if (showAccs.length > 0) {
103    busY   = devBtm + 38;
104    accTop = busY + 18;
105    y      = accTop + accRows * (AH + AGAP) - AGAP;
106  }
107  var H = y + 56;
108
109  var out = [];
110  out.push(_dgOpen(W, H));
111  out.push(_dgBg(W, H));
112  out.push(_dgTitleBar(W, model ? 'Configuration Diagram' : 'Configuration Diagram', PAD));
113
114  // Support badge
115  if (state.supportPlan) {
116    var sp = SUPPORT_PLANS.find(function(p) { return p.id === state.supportPlan; });
117    var spLabel = sp ? sp.title : state.supportPlan;
118    out.push('<text x="' + (W - PAD) + '" y="38" font-size="11" fill="' + _DG.textMid + '" text-anchor="end">' + escHtml(spLabel + ' · ' + state.supportYears + 'yr') + '</text>');
119  }
120
121  if (!model && !hasLH) {
122    out.push('<text x="' + cx + '" y="' + (H / 2 + 10) + '" font-size="14" fill="' + _DG.textMut + '" text-anchor="middle">Select a model to generate a diagram</text>');
123    out.push('</svg>');
124    return out.join('\n');
125  }
126
127  // Lighthouse
128  if (hasLH) {
129    out.push(_dgLhBox(cx - LHW / 2, lhTop, LHW, LHH, lhSku));
130    out.push(_dgLineV(cx, lhBtm, devTop));
131  }
132
133  // Device
134  if (model) {
135    out.push(_dgDeviceBox(cx - DW / 2, devTop, DW, DH, model.title, model.sku));
136  }
137
138  // Accessories
139  if (showAccs.length > 0) {
140    var totalW  = accCols * AW + (accCols - 1) * AGAP;
141    var aStartX = cx - totalW / 2;
142    var busX1   = aStartX + AW / 2;
143    var busX2   = aStartX + (accCols - 1) * (AW + AGAP) + AW / 2;
144
145    out.push(_dgLineV(cx, devBtm, busY));
146    if (accCols > 1) out.push(_dgLineH(busX1, busX2, busY));
147
148    showAccs.forEach(function(acc, i) {
149      var col = i % ACOLS, row = Math.floor(i / ACOLS);
150      var ax  = aStartX + col * (AW + AGAP);
151      var ay  = accTop  + row * (AH + AGAP);
152      var acx = ax + AW / 2;
153      out.push(_dgLineV(acx, busY, ay));
154      out.push(_dgAccBox(ax, ay, AW, AH, acc.title, acc.sku, acc.qty));
155    });
156
157    if (accList.length > 8) {
158      out.push('<text x="' + cx + '" y="' + (H - 18) + '" font-size="11" fill="' + _DG.textMut + '" text-anchor="middle">+ ' + (accList.length - 8) + ' more accessories not shown</text>');
159    }
160  }
161
162  out.push('</svg>');
163  return out.join('\n');
164}
165
166// ── DEPLOYMENT DIAGRAM ─────────────────────────────────────────────────────
167function _dgDeploy() {
168  var sites  = state.configs.filter(function(s) { return s.racks && s.racks.length > 0; });
169  var hasLH  = state.lighthouse === 'adding';
170  var lhSku  = hasLH ? getLighthouseSku() : null;
171
172  var PAD   = 48;
173  var SW    = 214;   // site box width
174  var SGAP  = 24;    // gap between sites
175  var SPAD  = 14;    // site inner padding
176  var RH    = 54;    // rack entry height
177  var RGAP  = 8;     // gap between racks
178  var SHDR  = 36;    // site header height
179  var LHW   = 250, LHH = 72;
180
181  function siteH(site) {
182    var n = (site.racks || []).length;
183    return SHDR + SPAD + n * RH + (n - 1) * RGAP + SPAD;
184  }
185  var maxSiteH = Math.max.apply(null, sites.map(siteH).concat([100]));
186
187  var COLS     = Math.min(4, sites.length);
188  var ROWS     = Math.ceil(sites.length / COLS);
189  var contentW = COLS * SW + (COLS - 1) * SGAP;
190  var W        = Math.max(760, contentW + PAD * 2);
191  var cx       = W / 2;
192
193  var y = 72;
194  var lhTop = 0, lhBtm = 0;
195  if (hasLH) { lhTop = y; lhBtm = lhTop + LHH; y = lhBtm + 44; }
196  var sitesTopY = y;
197  var H = sitesTopY + ROWS * (maxSiteH + SGAP) - SGAP + PAD;
198
199  var sitesStartX = cx - contentW / 2;
200
201  var out = [];
202  out.push(_dgOpen(W, H));
203  out.push(_dgBg(W, H));
204  out.push(_dgTitleBar(W, 'Deployment Diagram', PAD));
205
206  var totalRacks = sites.reduce(function(s, c) { return s + c.racks.length; }, 0);
207  var summary = sites.length + ' site' + (sites.length !== 1 ? 's' : '') + ' · ' + totalRacks + ' device' + (totalRacks !== 1 ? 's' : '');
208  out.push('<text x="' + (W - PAD) + '" y="38" font-size="11" fill="' + _DG.textMid + '" text-anchor="end">' + escHtml(summary) + '</text>');
209
210  if (hasLH) {
211    out.push(_dgLhBox(cx - LHW / 2, lhTop, LHW, LHH, lhSku));
212  }
213
214  // Bus from LH to sites
215  if (hasLH && sites.length > 0) {
216    var busY     = lhBtm + 22;
217    var firstCx  = sitesStartX + SW / 2;
218    var lastCx   = sitesStartX + (COLS - 1) * (SW + SGAP) + SW / 2;
219    out.push(_dgLineV(cx, lhBtm, busY));
220    if (sites.length > 1) out.push(_dgLineH(firstCx, lastCx, busY));
221
222    sites.slice(0, COLS).forEach(function(_, i) {
223      var scx = sitesStartX + i * (SW + SGAP) + SW / 2;
224      out.push(_dgLineV(scx, busY, sitesTopY));
225    });
226    // Additional rows: just a line from top of each site
227    if (ROWS > 1) {
228      sites.slice(COLS).forEach(function(_, i) {
229        var col = i % COLS, row = Math.floor(i / COLS) + 1;
230        var scx = sitesStartX + col * (SW + SGAP) + SW / 2;
231        var sy  = sitesTopY + row * (maxSiteH + SGAP);
232        // no bus for rows > 0, just a subtle label
233      });
234    }
235  }
236
237  sites.forEach(function(site, i) {
238    var col = i % COLS, row = Math.floor(i / COLS);
239    var sx  = sitesStartX + col * (SW + SGAP);
240    var sy  = sitesTopY + row * (maxSiteH + SGAP);
241    out.push(_dgSiteBox(sx, sy, SW, siteH(site), site.label || ('Site ' + (i + 1)), site.racks, SPAD, RH, RGAP, SHDR));
242  });
243
244  out.push('</svg>');
245  return out.join('\n');
246}
247
248// ── SVG PRIMITIVES ─────────────────────────────────────────────────────────
249function _dgOpen(w, h) {
250  return '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" font-family="-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif">';
251}
252
253function _dgBg(w, h) {
254  // Subtle dot grid
255  var dots = '<pattern id="dg" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="10" cy="10" r="0.8" fill="#cbd5e1"/></pattern>';
256  return '<defs>' + dots + '</defs><rect width="' + w + '" height="' + h + '" fill="' + _DG.bg + '"/><rect width="' + w + '" height="' + h + '" fill="url(#dg)"/>';
257}
258
259function _dgTitleBar(w, title, pad) {
260  return (
261    '<rect width="' + w + '" height="52" fill="' + _DG.titleBg + '"/>' +
262    '<text x="' + pad + '" y="33" font-size="14" font-weight="700" fill="white" letter-spacing="-0.3">' + escHtml(title) + '</text>' +
263    '<text x="' + pad + '" y="46" font-size="10" fill="rgba(255,255,255,0.4)" letter-spacing="0.5">OPENGEAR PRODUCT CONFIGURATOR</text>'
264  );
265}
266
267function _dgLineV(x, y1, y2) {
268  return '<line x1="' + x + '" y1="' + y1 + '" x2="' + x + '" y2="' + y2 + '" stroke="' + _DG.line + '" stroke-width="1.5"/>';
269}
270
271function _dgLineH(x1, x2, y) {
272  return '<line x1="' + x1 + '" y1="' + y + '" x2="' + x2 + '" y2="' + y + '" stroke="' + _DG.line + '" stroke-width="1.5"/>';
273}
274
275function _dgLhBox(x, y, w, h, sku) {
276  var out = [];
277  // Shadow
278  out.push('<rect x="' + (x+3) + '" y="' + (y+3) + '" width="' + w + '" height="' + h + '" rx="10" fill="rgba(0,0,0,0.12)"/>');
279  // Body
280  out.push('<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="10" fill="' + _DG.lh + '" stroke="' + _DG.lhStroke + '" stroke-width="1.5"/>');
281  // Icon (server cluster)
282  var ix = x + 16, iy = y + h / 2;
283  out.push('<g opacity="0.85">');
284  // Three server bars
285  [-10, 0, 10].forEach(function(dy, i) {
286    var barY = iy + dy - 4;
287    out.push('<rect x="' + ix + '" y="' + barY + '" width="30" height="8" rx="2" fill="none" stroke="rgba(255,255,255,0.4)" stroke-width="1.5"/>');
288    out.push('<circle cx="' + (ix + 7) + '" cy="' + (barY + 4) + '" r="2.5" fill="' + (i === 0 ? '#22c55e' : i === 1 ? '#f59e0b' : '#94a3b8') + '"/>');
289    out.push('<rect x="' + (ix + 14) + '" y="' + (barY + 2) + '" width="12" height="4" rx="1" fill="rgba(255,255,255,0.2)"/>');
290  });
291  out.push('</g>');
292  // Text
293  var tx = ix + 42;
294  out.push('<text x="' + tx + '" y="' + (iy - 12) + '" font-size="9.5" font-weight="700" fill="rgba(255,255,255,0.55)" letter-spacing="1.2">LIGHTHOUSE</text>');
295  out.push('<text x="' + tx + '" y="' + (iy + 7)  + '" font-size="13.5" font-weight="700" fill="white">'  + escHtml(sku || 'Opengear Lighthouse') + '</text>');
296  out.push('<text x="' + tx + '" y="' + (iy + 22) + '" font-size="9.5"  fill="rgba(255,255,255,0.45)">'  + escHtml(state.lighthouseTier === 'CORE' ? 'Core Edition' : 'Enhance Edition') + ' · ' + escHtml(state.lighthouseNodes + ' nodes') + '</text>');
297  return out.join('\n');
298}
299
300function _dgDeviceBox(x, y, w, h, title, sku) {
301  var out = [];
302  var cy = y + h / 2;
303
304  // Drop shadow
305  out.push('<rect x="' + (x+3) + '" y="' + (y+3) + '" width="' + w + '" height="' + h + '" rx="6" fill="rgba(0,0,0,0.18)"/>');
306  // Chassis body
307  out.push('<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="6" fill="' + _DG.rack + '" stroke="' + _DG.rackBezel + '" stroke-width="1.5"/>');
308  // Top bezel highlight strip
309  out.push('<rect x="' + (x+1) + '" y="' + (y+1) + '" width="' + (w-2) + '" height="5" rx="5" fill="' + _DG.rackHighlight + '"/>');
310  // Bottom edge shadow line
311  out.push('<rect x="' + (x+1) + '" y="' + (y+h-4) + '" width="' + (w-2) + '" height="3" rx="2" fill="rgba(0,0,0,0.25)"/>');
312
313  // Left panel section (LED area)
314  var panelW = 28;
315  out.push('<rect x="' + (x+1) + '" y="' + (y+1) + '" width="' + panelW + '" height="' + (h-2) + '" rx="5" fill="rgba(0,0,0,0.18)"/>');
316  // 2 green LEDs stacked on left
317  var ledX = x + 10;
318  var leds = [cy - 8, cy + 4];
319  leds.forEach(function(ly) {
320    out.push('<circle cx="' + ledX + '" cy="' + ly + '" r="5" fill="rgba(0,0,0,0.3)"/>');
321    out.push('<circle cx="' + ledX + '" cy="' + ly + '" r="3.5" fill="' + _DG.led + '"/>');
322    out.push('<circle cx="' + ledX + '" cy="' + ly + '" r="6" fill="' + _DG.ledGlow + '" opacity="0.22"/>');
323    out.push('<circle cx="' + (ledX-1) + '" cy="' + (ly-1) + '" r="1.2" fill="rgba(255,255,255,0.7)"/>');
324  });
325
326  // Port row in center
327  var portStartX = x + panelW + 12;
328  var numPorts = 12, portW = 7, portH = 10, portGap = 3;
329  for (var p = 0; p < numPorts; p++) {
330    var lit = p < 8;
331    var px = portStartX + p * (portW + portGap);
332    out.push('<rect x="' + px + '" y="' + (cy - portH/2) + '" width="' + portW + '" height="' + portH + '" rx="1.5" fill="' + (lit ? '#1a1a1a' : '#222') + '" stroke="' + _DG.rackBezel + '" stroke-width="0.5"/>');
333    if (lit) out.push('<rect x="' + (px+1.5) + '" y="' + (cy+portH/2-3) + '" width="' + (portW-3) + '" height="2" rx="1" fill="' + _DG.led + '" opacity="' + (p < 6 ? 0.85 : 0.35) + '"/>');
334  }
335
336  // Divider before label section
337  var divX = portStartX + numPorts * (portW + portGap) + 8;
338  out.push('<line x1="' + divX + '" y1="' + (y+8) + '" x2="' + divX + '" y2="' + (y+h-8) + '" stroke="' + _DG.rackBezel + '" stroke-width="1"/>');
339
340  // SKU / brand label
341  var tx = divX + 10;
342  if (tx + 60 < x + w - 8) {
343    out.push('<text x="' + tx + '" y="' + (cy - 7) + '" font-size="8.5" font-weight="700" fill="rgba(255,255,255,0.35)" letter-spacing="0.8">OPENGEAR</text>');
344    var skuDisp = (sku || title || '').length > 18 ? (sku || title).slice(0, 18) + '…' : (sku || title);
345    out.push('<text x="' + tx + '" y="' + (cy + 10) + '" font-size="12" font-weight="700" fill="white">' + escHtml(skuDisp) + '</text>');
346  }
347  return out.join('\n');
348}
349
350function _dgAccBox(x, y, w, h, title, sku, qty) {
351  var out = [];
352  out.push('<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="6" fill="' + _DG.acc + '" stroke="' + _DG.accStroke + '" stroke-width="1"/>');
353  // Cable icon
354  var ix = x + 10, iy = y + h / 2;
355  out.push('<rect x="' + ix + '" y="' + (iy - 7) + '" width="14" height="14" rx="3" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>');
356  out.push('<line x1="' + (ix + 14) + '" y1="' + iy + '" x2="' + (ix + 20) + '" y2="' + iy + '" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>');
357  // Text
358  var tx = ix + 24;
359  var skuDisp   = sku.length   > 16 ? sku.slice(0, 16) + '…'   : sku;
360  var titleDisp = title.length > 20 ? title.slice(0, 20) + '…' : title;
361  out.push('<text x="' + tx + '" y="' + (iy - 5) + '" font-size="9" fill="rgba(255,255,255,0.4)">' + escHtml(skuDisp) + '</text>');
362  out.push('<text x="' + tx + '" y="' + (iy + 11) + '" font-size="11" fill="white">' + escHtml(titleDisp) + '</text>');
363  if (qty > 1) {
364    out.push('<rect x="' + (x + w - 28) + '" y="' + (y + 10) + '" width="20" height="16" rx="8" fill="' + _DG.badge + '"/>');
365    out.push('<text x="' + (x + w - 18) + '" y="' + (y + 22) + '" font-size="9" font-weight="700" fill="white" text-anchor="middle">×' + qty + '</text>');
366  }
367  return out.join('\n');
368}
369
370function _dgSiteBox(x, y, w, h, label, racks, spad, rh, rgap, shdr) {
371  var out = [];
372
373  // ── Outer drop shadow ────────────────────────────────────────────────────
374  out.push('<rect x="' + (x+3) + '" y="' + (y+3) + '" width="' + w + '" height="' + h + '" rx="10" fill="rgba(0,0,0,0.10)"/>');
375
376  // ── Rack enclosure frame (metallic gray) ─────────────────────────────────
377  out.push('<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="10" fill="' + _DG.rackFrame + '" stroke="' + _DG.rackFrameStroke + '" stroke-width="1.5"/>');
378  // Inner inset — gives the recessed panel feel
379  var FP = 8; // frame padding
380  out.push('<rect x="' + (x+FP) + '" y="' + (y+shdr) + '" width="' + (w-FP*2) + '" height="' + (h-shdr-FP) + '" rx="4" fill="' + _DG.rackFrameInner + '" stroke="' + _DG.rackFrameStroke + '" stroke-width="0.75"/>');
381
382  // ── Site label header ────────────────────────────────────────────────────
383  out.push('<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + shdr + '" rx="10" fill="' + _DG.siteHdr + '"/>');
384  out.push('<rect x="' + x + '" y="' + (y+shdr-10) + '" width="' + w + '" height="10" fill="' + _DG.siteHdr + '"/>');
385  // Location pin emoji + label
386  out.push('<text x="' + (x+10) + '" y="' + (y+22) + '" font-size="12">📍</text>');
387  var lbl = label.length > 15 ? label.slice(0, 15) + '…' : label;
388  out.push('<text x="' + (x+27) + '" y="' + (y+22) + '" font-size="11.5" font-weight="700" fill="#374151">' + escHtml(lbl) + '</text>');
389  // Rack unit count chip
390  var chipTxt = racks.length + 'U';
391  out.push('<rect x="' + (x+w-36) + '" y="' + (y+8) + '" width="28" height="16" rx="8" fill="rgba(0,0,0,0.12)"/>');
392  out.push('<text x="' + (x+w-22) + '" y="' + (y+20) + '" font-size="9" font-weight="700" fill="#4b5563" text-anchor="middle">' + chipTxt + '</text>');
393
394  // ── Rack units ───────────────────────────────────────────────────────────
395  var rw  = w - (spad + FP) * 2 + FP;  // width inside inner panel
396  var rxi = x + FP + spad;
397  var rty = y + shdr + spad;
398
399  racks.forEach(function(rack, i) {
400    var ry  = rty + i * (rh + rgap);
401    var sku = rack.resolvedSku || rack.baseSku || 'Device';
402    var cy  = ry + rh / 2;
403
404    // Chassis drop shadow
405    out.push('<rect x="' + (rxi+2) + '" y="' + (ry+2) + '" width="' + rw + '" height="' + rh + '" rx="4" fill="rgba(0,0,0,0.22)"/>');
406    // Chassis body
407    out.push('<rect x="' + rxi + '" y="' + ry + '" width="' + rw + '" height="' + rh + '" rx="4" fill="' + _DG.rack + '" stroke="' + _DG.rackBezel + '" stroke-width="1"/>');
408    // Top bezel highlight
409    out.push('<rect x="' + (rxi+1) + '" y="' + (ry+1) + '" width="' + (rw-2) + '" height="4" rx="3" fill="' + _DG.rackHighlight + '"/>');
410    // Bottom shadow edge
411    out.push('<rect x="' + (rxi+1) + '" y="' + (ry+rh-3) + '" width="' + (rw-2) + '" height="2" rx="1" fill="rgba(0,0,0,0.3)"/>');
412
413    // Left LED panel recess
414    var panelW = 22;
415    out.push('<rect x="' + (rxi+1) + '" y="' + (ry+1) + '" width="' + panelW + '" height="' + (rh-2) + '" rx="3" fill="rgba(0,0,0,0.2)"/>');
416
417    // 2 green LEDs (status indicators) on left panel
418    var ledX = rxi + 10;
419    var ledPositions = [cy - 7, cy + 5];
420    ledPositions.forEach(function(ly) {
421      out.push('<circle cx="' + ledX + '" cy="' + ly + '" r="4.5" fill="rgba(0,0,0,0.4)"/>');    // bezel hole
422      out.push('<circle cx="' + ledX + '" cy="' + ly + '" r="3"   fill="' + _DG.led + '"/>');    // LED
423      out.push('<circle cx="' + ledX + '" cy="' + ly + '" r="5.5" fill="' + _DG.ledGlow + '" opacity="0.2"/>'); // glow
424      out.push('<circle cx="' + (ledX-1) + '" cy="' + (ly-1) + '" r="1" fill="rgba(255,255,255,0.65)"/>'); // specular
425    });
426
427    // Port connectors (small rectangles, 5 wide)
428    var portStartX = rxi + panelW + 8;
429    var numPorts = 5, portW = 7, portH = 12, portGap = 4;
430    for (var p = 0; p < numPorts; p++) {
431      var px = portStartX + p * (portW + portGap);
432      var active = p < 3;
433      out.push('<rect x="' + px + '" y="' + (cy-portH/2) + '" width="' + portW + '" height="' + portH + '" rx="1.5" fill="#1c1c1c" stroke="' + _DG.rackBezel + '" stroke-width="0.5"/>');
434      if (active) out.push('<rect x="' + (px+1.5) + '" y="' + (cy+portH/2-3) + '" width="' + (portW-3) + '" height="2" rx="1" fill="' + _DG.led + '" opacity="0.8"/>');
435    }
436
437    // Vertical divider before text
438    var dvx = portStartX + numPorts * (portW + portGap) + 8;
439    out.push('<line x1="' + dvx + '" y1="' + (ry+6) + '" x2="' + dvx + '" y2="' + (ry+rh-6) + '" stroke="' + _DG.rackBezel + '" stroke-width="0.75" stroke-dasharray="2,2"/>');
440
441    // SKU label
442    var textX = dvx + 7;
443    var skuDisp = sku.length > 13 ? sku.slice(0, 13) + '…' : sku;
444    out.push('<text x="' + textX + '" y="' + (cy+5) + '" font-size="10" font-weight="700" fill="white">' + escHtml(skuDisp) + '</text>');
445
446    // Accessory count badge (right)
447    var accCt = Object.keys(rack.accAdded || {}).length;
448    if (accCt > 0) {
449      var bx = rxi + rw - 30;
450      out.push('<rect x="' + bx + '" y="' + (ry+10) + '" width="26" height="13" rx="6.5" fill="' + _DG.badge + '"/>');
451      out.push('<text x="' + (bx+13) + '" y="' + (ry+21) + '" font-size="8" fill="white" text-anchor="middle">' + accCt + ' acc</text>');
452    }
453
454    // Support plan badge (bottom-left of chassis)
455    if (rack.supportPlan) {
456      var sp   = SUPPORT_PLANS.find(function(p) { return p.id === rack.supportPlan; });
457      var spLbl = sp ? sp.title.slice(0, 8) : rack.supportPlan.slice(0, 8);
458      out.push('<rect x="' + (rxi+3) + '" y="' + (ry+rh-11) + '" width="34" height="8" rx="4" fill="' + _DG.support + '" opacity="0.8"/>');
459      out.push('<text x="' + (rxi+20) + '" y="' + (ry+rh-5) + '" font-size="6.5" fill="white" text-anchor="middle">' + escHtml(spLbl) + '</text>');
460    }
461  });
462
463  return out.join('\n');
464}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.