Files
boc/projects/landvex/arkiv/byggscript/deck.js
T
Bernt 7d40cf95bf landvex: IOL v3 + Identify API + Neo4j-graf + byggpipeline
- FastAPI Identify API (port 8081)
- Neo4j propertygraf (64 noder, 80 kanter)
- Ingest-pipeline (Python)
- Byggscript + integrationstester
- Docker Compose setup
2026-07-05 05:55:48 +00:00

329 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const pptxgen = require("pptxgenjs");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const sharp = require("sharp");
const {
FaSearch, FaClipboardList, FaExclamationTriangle, FaLayerGroup, FaTree, FaIndustry,
FaTint, FaQuestionCircle, FaBullseye, FaCamera, FaCheckCircle, FaSyncAlt, FaShieldAlt,
FaExchangeAlt, FaHistory, FaRocket, FaChartLine, FaBuilding, FaLink, FaDatabase
} = require("react-icons/fa");
// Palett: asfalt + galvstål + signalgul (vägmarkering)
const DARK = "23272B", CARD_D = "2E343B", INK = "2A3138", BODY = "3A4149";
const MUTED = "6B7480", MUTED_L = "AEB6BF", LIGHT = "F3F5F7", WHITE = "FFFFFF";
const YEL = "F5B301", YEL_TINT = "FFF6DC", LINE = "D9DEE4";
const H = "Arial", B = "Calibri", MONO = "Courier New";
const mkShadow = () => ({ type: "outer", color: "000000", blur: 7, offset: 2, angle: 45, opacity: 0.14 });
async function icon(Comp, color, size = 256) {
const svg = ReactDOMServer.renderToStaticMarkup(React.createElement(Comp, { color, size: String(size) }));
const png = await sharp(Buffer.from(svg)).png().toBuffer();
return "image/png;base64," + png.toString("base64");
}
(async () => {
const P = {};
P.search = await icon(FaSearch, "#" + INK);
P.clip = await icon(FaClipboardList, "#" + INK);
P.warn = await icon(FaExclamationTriangle, "#" + INK);
P.layer = await icon(FaLayerGroup, "#" + INK);
P.tree = await icon(FaTree, "#" + INK);
P.ind = await icon(FaIndustry, "#" + INK);
P.tint = await icon(FaTint, "#" + INK);
P.q = await icon(FaQuestionCircle, "#" + WHITE);
P.bull = await icon(FaBullseye, "#" + WHITE);
P.cam = await icon(FaCamera, "#" + WHITE);
P.check = await icon(FaCheckCircle, "#" + WHITE);
P.sync = await icon(FaSyncAlt, "#" + INK);
P.shield = await icon(FaShieldAlt, "#" + INK);
P.exch = await icon(FaExchangeAlt, "#" + INK);
P.hist = await icon(FaHistory, "#" + INK);
P.rocket = await icon(FaRocket, "#" + INK);
P.chart = await icon(FaChartLine, "#" + INK);
P.build = await icon(FaBuilding, "#" + INK);
P.link = await icon(FaLink, "#" + INK);
P.db = await icon(FaDatabase, "#" + INK);
P.clipY = await icon(FaClipboardList, "#" + INK);
P.warnY = await icon(FaExclamationTriangle, "#" + INK);
P.checkY = await icon(FaCheckCircle, "#" + INK);
const pres = new pptxgen();
pres.layout = "LAYOUT_16x9";
pres.author = "Landvex";
pres.title = "Landvex — Infrastructure Object Library, pilotpitch";
const title = (s, t, dark = false) =>
s.addText(t, { x: 0.6, y: 0.42, w: 8.8, h: 0.7, fontFace: H, fontSize: 36, bold: true, color: dark ? WHITE : INK, margin: 0 });
const circleIcon = (s, data, cx, cy, d, fill) => {
s.addShape(pres.shapes.OVAL, { x: cx, y: cy, w: d, h: d, fill: { color: fill } });
const pad = d * 0.26;
s.addImage({ data, x: cx + pad, y: cy + pad, w: d - 2 * pad, h: d - 2 * pad });
};
// ---------- S1 · Titel (mörk) ----------
let s = pres.addSlide();
s.background = { color: DARK };
s.addText("LANDVEX × QUIXZOOM", { x: 0.6, y: 0.55, w: 5.8, h: 0.35, fontFace: H, fontSize: 13, bold: true, color: YEL, charSpacing: 4, margin: 0 });
s.addText([
{ text: "Infrastructure", options: { breakLine: true } },
{ text: "Object Library" }
], { x: 0.6, y: 1.05, w: 5.9, h: 1.8, fontFace: H, fontSize: 42, bold: true, color: WHITE, margin: 0, lineSpacing: 46 });
s.addText("Vet vad som står där ute — varje objekt i gaturummet, med källa på varje faktum.", { x: 0.6, y: 2.95, w: 5.5, h: 0.75, fontFace: B, fontSize: 15, color: MUTED_L, margin: 0 });
s.addText("37 klasser · 26 modeller · 167 grafkanter · 0 valideringsfel", { x: 0.6, y: 3.85, w: 5.7, h: 0.35, fontFace: H, fontSize: 13, bold: true, color: YEL, margin: 0 });
s.addText("Pilotpitch · juli 2026", { x: 0.6, y: 5.05, w: 4, h: 0.3, fontFace: B, fontSize: 11, color: MUTED, margin: 0 });
const chips = [
["LVX-TRP-0102", "Vägbelysningsarmatur"],
["LVX-P-0000026", "Tesla Supercharger V4"],
["LVX-VAT-0103", "Brandpost / hydrant"],
["LVX-ELN-0101", "Kabelskåp lågspänning"]
];
chips.forEach((c, i) => {
const y = 1.05 + i * 0.82;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 6.35, y, w: 3.05, h: 0.62, rectRadius: 0.06, fill: { color: CARD_D } });
s.addText([
{ text: c[0], options: { fontFace: MONO, fontSize: 11.5, bold: true, color: YEL, breakLine: true } },
{ text: c[1], options: { fontFace: B, fontSize: 10.5, color: WHITE } }
], { x: 6.55, y: y + 0.05, w: 2.75, h: 0.52, margin: 0, lineSpacing: 13 });
});
s.addNotes("Landvex bygger referensbiblioteket för fysisk infrastruktur. QUIXZOOM är fältarmen. Siffrorna är dagens bibliotek — validerat, inte vision.");
// ---------- S2 · Problemet ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Gatan saknar identitet");
const rows2 = [
[P.clip, "Inventering är manuell och dyr", "Beståndsdata byggs om från noll vid varje upphandling och ägarbyte."],
[P.warn, "Utgångna modeller upptäcks för sent", "Reservdelsrisken syns först när armaturen slocknat eller locket spruckit."],
[P.search, "\u201dVad är detta?\u201d saknar svar i fält", "Tekniker, system och AI gissar — det finns ingen gemensam referens."]
];
rows2.forEach((r, i) => {
const y = 1.4 + i * 1.15;
circleIcon(s, r[0], 0.6, y, 0.52, YEL);
s.addText(r[1], { x: 1.35, y: y - 0.06, w: 4.15, h: 0.35, fontFace: H, fontSize: 15, bold: true, color: INK, margin: 0 });
s.addText(r[2], { x: 1.35, y: y + 0.3, w: 4.15, h: 0.62, fontFace: B, fontSize: 12, color: MUTED, margin: 0 });
});
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 5.85, y: 1.35, w: 3.55, h: 3.4, rectRadius: 0.09, fill: { color: WHITE }, line: { color: LINE, width: 1 }, shadow: mkShadow() });
s.addText("60M+", { x: 6.1, y: 1.6, w: 3.05, h: 0.95, fontFace: H, fontSize: 58, bold: true, color: INK, margin: 0 });
s.addText("elstolpar enbart i USA — de flesta bär identiteten inbränd i godset.", { x: 6.1, y: 2.65, w: 3.05, h: 0.85, fontFace: B, fontSize: 12.5, color: MUTED, margin: 0 });
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 6.1, y: 3.7, w: 3.05, h: 0.62, rectRadius: 0.06, fill: { color: YEL_TINT } });
s.addText("birthmark: år · klass · impregnering", { x: 6.28, y: 3.78, w: 2.75, h: 0.46, fontFace: MONO, fontSize: 10.5, bold: true, color: INK, margin: 0, valign: "middle" });
s.addNotes("Problemet är inte brist på data — det är brist på identitet. Ingen gemensam nyckel mellan fält, system och leverantörer.");
// ---------- S3 · Lösningen: fem nivåer ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Ett bibliotek — fem nivåer");
const steps = [
["Domän", "TRP · VAT · ELN…"],
["Objektklass", "LVX-TRP-0102"],
["Typ", "Cobrahead-LED"],
["Produktmodell", "LVX-P-0000003"],
["Individ", "LVX-I-… (pilot)"]
];
steps.forEach((st, i) => {
const x = 0.6 + i * 1.9;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.35, w: 1.55, h: 0.95, rectRadius: 0.08, fill: { color: LIGHT } });
s.addText([
{ text: st[0], options: { fontFace: H, fontSize: 12.5, bold: true, color: INK, breakLine: true } },
{ text: st[1], options: { fontFace: MONO, fontSize: 9.5, color: MUTED } }
], { x: x + 0.12, y: 1.47, w: 1.31, h: 0.75, margin: 0, lineSpacing: 14 });
if (i < 4) s.addText("\u203A", { x: x + 1.55, y: 1.55, w: 0.35, h: 0.5, fontFace: H, fontSize: 20, color: MUTED, align: "center", margin: 0 });
});
const cards3 = [
[P.link, "Permanenta LVX-ID", "Identiteten överlever ägarbyten, versioner och systembyten — referensen alla kan peka på."],
[P.db, "Källa på varje faktum", "Problem utan källa publiceras aldrig. PLATSHÅLLARE-regeln är hård — juridik by design."],
[P.hist, "Bitemporal fakta", "Vad som gällde där ute — och när vi visste det. Ersättningskedjor blir spårbara."]
];
cards3.forEach((c, i) => {
const x = 0.6 + i * 3.07;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 2.85, w: 2.87, h: 2.15, rectRadius: 0.09, fill: { color: LIGHT } });
circleIcon(s, c[0], x + 0.22, y3(i), 0.46, YEL);
s.addText(c[1], { x: x + 0.85, y: 3.05, w: 1.95, h: 0.6, fontFace: H, fontSize: 13.5, bold: true, color: INK, margin: 0 });
s.addText(c[2], { x: x + 0.22, y: 3.75, w: 2.45, h: 1.1, fontFace: B, fontSize: 11.5, color: BODY, margin: 0 });
});
function y3() { return 3.05; }
s.addNotes("Fem nivåer: domän ned till individ. Piloten fyller individlagret för kundens geografi. ID:na är permanenta — det är vallgraven.");
// ---------- S4 · Bevis ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Byggt — och validerat");
const stats = [
["37", "objektklasser — 31 källbelagda"],
["26", "produktmodeller — alla källbelagda"],
["167", "grafkanter i konsolidering v2"],
["0", "valideringsfel mot schemat"]
];
stats.forEach((st, i) => {
const x = 0.6 + i * 2.25;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.3, w: 2.05, h: 1.45, rectRadius: 0.09, fill: { color: WHITE }, line: { color: LINE, width: 1 }, shadow: mkShadow() });
s.addText(st[0], { x: x + 0.18, y: 1.42, w: 1.7, h: 0.7, fontFace: H, fontSize: 40, bold: true, color: i === 3 ? YEL : INK, margin: 0 });
s.addText(st[1], { x: x + 0.18, y: 2.14, w: 1.72, h: 0.55, fontFace: B, fontSize: 10.5, color: MUTED, margin: 0 });
});
s.addText("Verifieringstrappan — varje fältfoto lyfter posten ett steg", { x: 0.6, y: 3.12, w: 8.8, h: 0.3, fontFace: B, fontSize: 12.5, italic: true, color: MUTED, margin: 0 });
const stair = ["Obekräftad", "Källbelagd", "Fältverifierad", "Tillverkarbekräftad"];
stair.forEach((t, i) => {
const h = 0.55 + i * 0.28, x = 0.6 + i * 2.25;
s.addShape(pres.shapes.RECTANGLE, { x, y: 5.05 - h, w: 2.05, h, fill: { color: i === 3 ? YEL : ["E7EAEE", "D5DAE0", "BFC7CF"][i] } });
s.addText(t, { x: x + 0.1, y: 5.05 - h + 0.08, w: 1.85, h: 0.35, fontFace: H, fontSize: 11.5, bold: true, color: INK, margin: 0 });
});
s.addNotes("Detta är inte en idé — biblioteket finns, är källbelagt och maskinvaliderat. Trappan är kvalitetsmodellen: piloten flyttar poster till fältverifierad.");
// ---------- S5 · OCR-guldet ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Identiteten sitter i godset");
const cards5 = [
[P.tree, "Stolpens birthmark", "Inbränd märkning i ögonhöjd: år, längd, klass och impregneringsmedel.", "år · klass · CCA/penta"],
[P.ind, "Gjutmärket", "Tillverkare, funktion och lastklass gjuts in i lock och ram.", "ULEFOS · EN124-2 · D400"],
[P.tint, "Hydrantens kropp", "AWWA kräver gjuten märkning — tillverkningsår och öppningsriktning.", "AMERICAN-DARLING · 1998"]
];
cards5.forEach((c, i) => {
const x = 0.6 + i * 3.07;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.3, w: 2.87, h: 3.15, rectRadius: 0.09, fill: { color: LIGHT } });
circleIcon(s, c[0], x + 0.22, 1.55, 0.5, YEL);
s.addText(c[1], { x: x + 0.88, y: 1.6, w: 1.95, h: 0.6, fontFace: H, fontSize: 14, bold: true, color: INK, margin: 0 });
s.addText(c[2], { x: x + 0.22, y: 2.35, w: 2.45, h: 1.05, fontFace: B, fontSize: 11.5, color: BODY, margin: 0 });
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: x + 0.22, y: 3.6, w: 2.43, h: 0.55, rectRadius: 0.06, fill: { color: WHITE }, line: { color: LINE, width: 1 } });
s.addText(c[3], { x: x + 0.36, y: 3.66, w: 2.2, h: 0.43, fontFace: MONO, fontSize: 10, bold: true, color: INK, margin: 0, valign: "middle" });
});
s.addText("Ingen databas krävs på plats — objektet bär sin egen identitet. OCR-först är protokollets kärna.", { x: 0.6, y: 4.75, w: 8.8, h: 0.35, fontFace: B, fontSize: 13, italic: true, color: MUTED, margin: 0 });
s.addNotes("Läsbarheten är affärsidén: birthmarks, gjutmärken och typskyltar gör varje foto till en databaspost.");
// ---------- S6 · Flyhjulet ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Flyhjulet: QUIXZOOM");
const nodes = [
[2.3, 1.12, P.q, "Kundfråga", "identify → okänd"],
[3.85, 2.38, P.bull, "Bounty", "skapas automatiskt"],
[2.3, 3.6, P.cam, "Zoomer-foto", "OCR + position"],
[0.75, 2.38, P.check, "Fältverifierad", "post + individ"]
];
nodes.forEach(n => {
circleIcon(s, n[2], n[0], n[1], 0.95, DARK);
s.addText(n[3], { x: n[0] - 0.45, y: n[1] + 0.92, w: 1.85, h: 0.28, fontFace: H, fontSize: 11.5, bold: true, color: INK, align: "center", margin: 0 });
s.addText(n[4], { x: n[0] - 0.45, y: n[1] + 1.16, w: 1.85, h: 0.24, fontFace: B, fontSize: 9.5, color: MUTED, align: "center", margin: 0 });
});
circleIcon(s, P.sync, 2.5, 2.6, 0.55, YEL);
s.addText("\u2198", { x: 3.5, y: 2.0, w: 0.4, h: 0.4, fontFace: H, fontSize: 16, color: MUTED, margin: 0 });
s.addText("\u2199", { x: 3.5, y: 3.3, w: 0.4, h: 0.4, fontFace: H, fontSize: 16, color: MUTED, margin: 0 });
s.addText("\u2196", { x: 1.85, y: 3.3, w: 0.4, h: 0.4, fontFace: H, fontSize: 16, color: MUTED, margin: 0 });
s.addText("\u2197", { x: 1.85, y: 2.0, w: 0.4, h: 0.4, fontFace: H, fontSize: 16, color: MUTED, margin: 0 });
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 5.85, y: 1.3, w: 3.55, h: 3.2, rectRadius: 0.09, fill: { color: LIGHT } });
s.addText("Bounty-motorn", { x: 6.1, y: 1.5, w: 3.05, h: 0.35, fontFace: H, fontSize: 14, bold: true, color: INK, margin: 0 });
s.addText([
{ text: "16 aktiva bounties ur posternas OCR-fält", options: { bullet: true, breakLine: true } },
{ text: "Poäng: bas · först-på-individ ×2 · full OCR +50 %", options: { bullet: true, breakLine: true } },
{ text: "Hårda säkerhetsregler — allt fotas från gångyta", options: { bullet: true, breakLine: true } },
{ text: "Varje \u201dobekräftad\u201d i API:t skapar en bounty", options: { bullet: true } }
], { x: 6.1, y: 1.95, w: 3.1, h: 2.4, fontFace: B, fontSize: 11.5, color: BODY, paraSpaceAfter: 8, margin: 0 });
s.addText("Kundens fråga finansierar datan som gör nästa svar bättre.", { x: 0.6, y: 5.12, w: 8.8, h: 0.35, fontFace: B, fontSize: 13, italic: true, color: MUTED, margin: 0 });
s.addNotes("Flyhjulet: obesvarade kundfrågor blir bounties, Zoomers fotograferar, posten fältverifieras, svaret blir bättre. Andelen kunddrivna bounties är hälsotalet.");
// ---------- S7 · API:t ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Produkten: Object API v0");
const eps = [
["POST", "/v0/identify", "bild in → modell ut"],
["GET", "/v0/objects/{lvx_id}", "hela posten + källor"],
["GET", "/v0/objects/{id}/succession", "ersättningskedjan"],
["GET", "/v0/search", "fritext + filter"],
["GET", "/v0/standards/{beteckning}", "objekt per standard"],
["POST", "/v0/feedback", "fel/okänt → bounty"]
];
eps.forEach((e, i) => {
const y = 1.3 + i * 0.58;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 0.6, y, w: 0.62, h: 0.32, rectRadius: 0.05, fill: { color: e[0] === "POST" ? YEL : CARD_D } });
s.addText(e[0], { x: 0.6, y, w: 0.62, h: 0.32, fontFace: H, fontSize: 9, bold: true, color: e[0] === "POST" ? INK : WHITE, align: "center", valign: "middle", margin: 0 });
s.addText(e[1], { x: 1.35, y: y + 0.01, w: 2.62, h: 0.32, fontFace: MONO, fontSize: 10.5, bold: true, color: INK, margin: 0, valign: "middle" });
s.addText(e[2], { x: 4.02, y: y + 0.01, w: 1.72, h: 0.32, fontFace: B, fontSize: 9.5, color: MUTED, margin: 0, valign: "middle" });
});
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 5.85, y: 1.3, w: 3.55, h: 3.45, rectRadius: 0.09, fill: { color: WHITE }, line: { color: LINE, width: 1 }, shadow: mkShadow() });
circleIcon(s, P.shield, 6.1, 1.52, 0.46, YEL);
s.addText("Svarskontraktet", { x: 6.72, y: 1.58, w: 2.6, h: 0.35, fontFace: H, fontSize: 14, bold: true, color: INK, margin: 0 });
s.addText([
{ text: "verifieringsnivå — alltid med", options: { bullet: true, breakLine: true } },
{ text: "konfidens 01 — aldrig dold", options: { bullet: true, breakLine: true } },
{ text: "källor[] på varje påstående", options: { bullet: true, breakLine: true } },
{ text: "PLATSHÅLLARE filtreras bort vid serialisering", options: { bullet: true, breakLine: true } },
{ text: "svagt svar ⇒ bounty-ID följer med", options: { bullet: true } }
], { x: 6.1, y: 2.15, w: 3.1, h: 2.4, fontFace: B, fontSize: 11.5, color: BODY, paraSpaceAfter: 8, margin: 0 });
s.addText("Juridiken sitter i kontraktet — referenser och beteckningar, aldrig standardtext.", { x: 0.6, y: 5.05, w: 8.8, h: 0.35, fontFace: B, fontSize: 13, italic: true, color: MUTED, margin: 0 });
s.addNotes("Sex endpoints räcker för piloten. Kontraktet är differentieringen: ärlighet och proveniens är obligatoriska fält, inte fotnoter.");
// ---------- S8 · Golden cases ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Tre svar som säljer");
const g = [
[P.exch, "Ersättningskedjan", "GE ERS identifieras → status: ersatt → ersättare: ERL-serien. Reservdelsrisken syns innan lagret tar slut.", "identify → succession"],
[P.hist, "Årsmodell ur godset", "American-Darling B-84-B, gjutet 1998, öppnar vänster — individdata utan register.", "konfidens 0,97 · källbelagd"],
[P.bull2 || P.search, "Ärlig när den inte vet", "Okänt UK-skåp → bästa klass, konfidens 0,71 — och en bounty skapas automatiskt.", "svar + BNTY-A0001"]
];
g.forEach((c, i) => {
const x = 0.6 + i * 3.07;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.3, w: 2.87, h: 3.15, rectRadius: 0.09, fill: { color: LIGHT } });
circleIcon(s, c[0], x + 0.22, 1.55, 0.5, YEL);
s.addText(c[1], { x: x + 0.88, y: 1.6, w: 1.95, h: 0.6, fontFace: H, fontSize: 14, bold: true, color: INK, margin: 0 });
s.addText(c[2], { x: x + 0.22, y: 2.35, w: 2.45, h: 1.15, fontFace: B, fontSize: 11.5, color: BODY, margin: 0 });
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: x + 0.22, y: 3.6, w: 2.43, h: 0.55, rectRadius: 0.06, fill: { color: WHITE }, line: { color: LINE, width: 1 } });
s.addText(c[3], { x: x + 0.36, y: 3.66, w: 2.2, h: 0.43, fontFace: MONO, fontSize: 10, bold: true, color: INK, margin: 0, valign: "middle" });
});
s.addText("Alla tre körs på riktiga poster — demodatat ingår i pilotpaketet.", { x: 0.6, y: 4.75, w: 8.8, h: 0.35, fontFace: B, fontSize: 13, italic: true, color: MUTED, margin: 0 });
s.addNotes("Tre golden cases som demoas live: succession, individdata ur gjutgods, och ärlighet + bounty vid okänt objekt.");
// ---------- S9 · Paketering ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Paketering");
const pk = [
[P.rocket, "Pilot", "13 designpartners", ["Fast månadsavgift, generös kvot", "Gemensam backlog styr utvecklingen", "Betalar även med fältaccess"], LIGHT],
[P.chart, "Pro", "entreprenad · besiktning · försäkring", ["Usage-based per identify", "Billiga uppslag driver LVX-ID-inlåsning", "Volymtrappa"], YEL_TINT],
[P.build, "Enterprise", "nätbolag · förvaltare · tillverkare", ["SLA + eget individlager (LVX-I)", "Bulkexport och integrationer", "Riktade bounties i egen geografi"], LIGHT]
];
pk.forEach((c, i) => {
const x = 0.6 + i * 3.07;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.3, w: 2.87, h: 3.1, rectRadius: 0.09, fill: { color: c[4] }, shadow: i === 1 ? mkShadow() : undefined });
circleIcon(s, c[0], x + 0.22, 1.52, 0.46, WHITE === c[4] ? LIGHT : WHITE);
s.addText(c[1], { x: x + 0.82, y: 1.55, w: 1.95, h: 0.35, fontFace: H, fontSize: 16, bold: true, color: INK, margin: 0 });
s.addText(c[2], { x: x + 0.22, y: 2.12, w: 2.45, h: 0.3, fontFace: B, fontSize: 10.5, italic: true, color: MUTED, margin: 0 });
s.addText(c[3].map((t, j) => ({ text: t, options: { bullet: true, breakLine: j < 2 } })), { x: x + 0.22, y: 2.5, w: 2.5, h: 1.75, fontFace: B, fontSize: 11, color: BODY, paraSpaceAfter: 7, margin: 0 });
});
s.addText("Identifieringen är värdet · uppslaget är räckvidden · bounty-krediter köper fältverifiering där kunden behöver den.", { x: 0.6, y: 4.75, w: 8.8, h: 0.35, fontFace: B, fontSize: 12.5, italic: true, color: MUTED, margin: 0 });
s.addNotes("SaaS-logiken: identify prissätts per värdefullt svar, uppslag hålls billiga för att göra LVX-ID till branschreferens, Enterprise får eget individlager.");
// ---------- S10 · Piloten (mörk) ----------
s = pres.addSlide();
s.background = { color: DARK };
title(s, "Piloten: 90 dagar till bevis", true);
const uc = [
[P.clipY, "Inventering", "Fota beståndet → klass, modell och status per objekt."],
[P.warnY, "Utgånget-listan", "Allt med status ersatt/utgången — med ersättare och källa."],
[P.checkY, "Problemflagg", "Källbelagda klassproblem matchade mot ert bestånd."]
];
uc.forEach((r, i) => {
const y = 1.45 + i * 1.1;
circleIcon(s, r[0], 0.6, y, 0.52, YEL);
s.addText(r[1], { x: 1.35, y: y - 0.05, w: 4.1, h: 0.35, fontFace: H, fontSize: 15, bold: true, color: WHITE, margin: 0 });
s.addText(r[2], { x: 1.35, y: y + 0.31, w: 4.1, h: 0.6, fontFace: B, fontSize: 12, color: MUTED_L, margin: 0 });
});
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 5.85, y: 1.35, w: 3.55, h: 3.35, rectRadius: 0.09, fill: { color: CARD_D } });
s.addText("Erbjudandet", { x: 6.1, y: 1.55, w: 3.05, h: 0.35, fontFace: H, fontSize: 14, bold: true, color: WHITE, margin: 0 });
s.addText([
{ text: "Avgränsat område, 90 dagar", options: { bullet: true, breakLine: true } },
{ text: "Fast pilotavgift — kvittas mot årsavtal", options: { bullet: true, breakLine: true } },
{ text: "Gemensam backlog styr bounties", options: { bullet: true, breakLine: true } },
{ text: "Ni behåller export av ert individlager", options: { bullet: true } }
], { x: 6.1, y: 2.0, w: 3.1, h: 1.85, fontFace: B, fontSize: 11.5, color: MUTED_L, paraSpaceAfter: 8, margin: 0 });
s.addText("Boka pilotworkshop \u2192", { x: 6.1, y: 4.05, w: 3.05, h: 0.45, fontFace: H, fontSize: 17, bold: true, color: YEL, margin: 0 });
s.addText("LANDVEX × QUIXZOOM · juli 2026", { x: 0.6, y: 5.08, w: 5, h: 0.3, fontFace: B, fontSize: 11, color: MUTED, charSpacing: 2, margin: 0 });
s.addNotes("Stäng med erbjudandet: 90 dagar, avgränsat område, tre användningsfall, fast avgift som kvittas. Nästa steg är en workshop där området och backloggen väljs.");
await pres.writeFile({ fileName: "/home/claude/landvex-pilotpitch.pptx" });
console.log("deck skriven");
})().catch(e => { console.error(e); process.exit(1); });