diff --git a/static/projections/index.html b/static/projections/index.html
index e3bf0419d..01e8efc46 100644
--- a/static/projections/index.html
+++ b/static/projections/index.html
@@ -196,22 +196,55 @@
Expenses (Operational & Financing)
return svg;
};
- const createPinGrid = (value) => {
- const TEN_K_PIN_VALUE = 10_000;
- const HUNDRED_K_PIN_VALUE = 100_000;
- const absValue = Math.abs(value);
+ const TEN_K_PIN_VALUE = 10_000;
+ const HUNDRED_K_PIN_VALUE = 100_000;
+ const evaluatePinBreakdown = (value) => {
+ const absValue = Math.abs(value);
const hundredPinCount = Math.floor(absValue / HUNDRED_K_PIN_VALUE);
const remainingAfterHundreds = absValue - hundredPinCount * HUNDRED_K_PIN_VALUE;
const tenPinCount = Math.floor(remainingAfterHundreds / TEN_K_PIN_VALUE);
const totalPinCount = hundredPinCount + tenPinCount;
+ return {
+ hundredPinCount,
+ tenPinCount,
+ totalPinCount,
+ };
+ };
+
+ const determineShipVariant = (pinCapacity) => {
+ if (pinCapacity <= 4) return 'patrol';
+ if (pinCapacity <= 8) return 'submarine';
+ if (pinCapacity <= 14) return 'destroyer';
+ if (pinCapacity <= 20) return 'battleship';
+ return 'carrier';
+ };
+
+ const determineGridColumns = (pinCapacity) => {
+ if (pinCapacity <= 4) return 2;
+ if (pinCapacity <= 6) return 3;
+ if (pinCapacity <= 12) return 4;
+ if (pinCapacity <= 18) return 5;
+ return 6;
+ };
+
+ const createPinGrid = (value, pinCapacity) => {
+ const { hundredPinCount, tenPinCount, totalPinCount } = evaluatePinBreakdown(value);
+
if (totalPinCount === 0) return null;
const pinGrid = document.createElement('div');
pinGrid.className = 'pin-grid';
- const maxVisiblePins = 200;
+ const gridCapacity = Math.max(pinCapacity, totalPinCount);
+ const gridColumns = determineGridColumns(gridCapacity);
+ const gridRows = Math.max(1, Math.ceil(gridCapacity / gridColumns));
+ pinGrid.style.setProperty('--pin-grid-columns', gridColumns);
+ pinGrid.style.setProperty('--pin-grid-rows', gridRows);
+ pinGrid.setAttribute('data-pin-capacity', String(pinCapacity));
+
+ const maxVisiblePins = 240;
let renderedPins = 0;
const makePin = (title, classes) => {
@@ -250,28 +283,134 @@ Expenses (Operational & Financing)
return pinGrid;
};
+ let hullIdCounter = 0;
+ const nextHullGradientId = (variant) => {
+ hullIdCounter += 1;
+ return `hull-gradient-${variant}-${hullIdCounter}`;
+ };
+
+ const hullShapes = {
+ patrol: 'M8,60 L26,42 L94,38 L126,46 L150,62 L8,62 Z',
+ submarine: 'M6,64 L30,42 L140,36 L182,44 L210,62 L6,64 Z',
+ destroyer: 'M6,66 L28,44 L168,36 L210,46 L242,60 L256,68 L6,66 Z',
+ battleship: 'M6,66 L32,42 L198,34 L238,46 L278,60 L298,70 L6,66 Z',
+ carrier: 'M6,68 L34,40 L230,32 L280,44 L320,60 L332,72 L6,68 Z',
+ };
+
+ const createHullSVG = (variant) => {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('class', 'battleship-hull');
+ svg.setAttribute('viewBox', '0 0 340 100');
+ svg.setAttribute('role', 'presentation');
+ svg.setAttribute('aria-hidden', 'true');
+
+ const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
+ const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
+ const gradientId = nextHullGradientId(variant);
+ gradient.setAttribute('id', gradientId);
+ gradient.setAttribute('x1', '0%');
+ gradient.setAttribute('x2', '100%');
+ gradient.setAttribute('y1', '0%');
+ gradient.setAttribute('y2', '100%');
+
+ const stopOne = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
+ stopOne.setAttribute('offset', '0%');
+ stopOne.setAttribute('stop-color', 'rgba(71, 85, 105, 0.95)');
+ gradient.appendChild(stopOne);
+
+ const stopTwo = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
+ stopTwo.setAttribute('offset', '70%');
+ stopTwo.setAttribute('stop-color', 'rgba(51, 65, 85, 0.9)');
+ gradient.appendChild(stopTwo);
+
+ defs.appendChild(gradient);
+ svg.appendChild(defs);
+
+ const hullPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ hullPath.setAttribute('d', hullShapes[variant] || hullShapes.destroyer);
+ hullPath.setAttribute('fill', `url(#${gradientId})`);
+ hullPath.setAttribute('stroke', 'rgba(15, 23, 42, 0.85)');
+ hullPath.setAttribute('stroke-width', '3');
+ hullPath.setAttribute('stroke-linejoin', 'round');
+ hullPath.setAttribute('stroke-linecap', 'round');
+ svg.appendChild(hullPath);
+
+ const deck = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ deck.setAttribute('x', '60');
+ deck.setAttribute('y', '44');
+ deck.setAttribute('width', '180');
+ deck.setAttribute('height', '16');
+ deck.setAttribute('rx', '6');
+ deck.setAttribute('fill', 'rgba(148, 163, 184, 0.35)');
+ svg.appendChild(deck);
+
+ const bridge = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ bridge.setAttribute('x', '110');
+ bridge.setAttribute('y', '36');
+ bridge.setAttribute('width', '46');
+ bridge.setAttribute('height', '18');
+ bridge.setAttribute('rx', '6');
+ bridge.setAttribute('fill', 'rgba(226, 232, 240, 0.45)');
+ svg.appendChild(bridge);
+
+ const portholeGroup = document.createDocumentFragment();
+ [0, 1, 2, 3].forEach((index) => {
+ const porthole = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ porthole.setAttribute('cx', String(80 + index * 36));
+ porthole.setAttribute('cy', '52');
+ porthole.setAttribute('r', '5');
+ porthole.setAttribute('fill', 'rgba(226, 232, 240, 0.6)');
+ porthole.setAttribute('stroke', 'rgba(15, 23, 42, 0.6)');
+ porthole.setAttribute('stroke-width', '1.5');
+ portholeGroup.appendChild(porthole);
+ });
+ svg.appendChild(portholeGroup);
+
+ return svg;
+ };
+
const buildShip = (nominal) => {
+ const pinCapacity = nominal.pinCapacity || 0;
+ const variant = determineShipVariant(pinCapacity);
const ship = document.createElement('div');
- ship.className = 'battleship-container animate-fade-in';
+ ship.className = `battleship-container animate-fade-in ship-${variant}`;
- // Create SVG battleship shape - horizontal vessel like the board game
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.setAttribute('class', 'battleship-shape');
- svg.setAttribute('viewBox', '0 0 300 60');
- svg.setAttribute('preserveAspectRatio', 'none');
-
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- // Horizontal battleship: pointed bow on left, flat stern on right, with superstructure blocks
- path.setAttribute('d', 'M10,45 L30,30 L60,30 L60,35 L90,35 L90,25 L120,25 L120,20 L150,20 L150,25 L180,25 L180,35 L240,35 L240,30 L270,30 L270,45 L280,45 L280,50 L10,50 Z');
- path.setAttribute('fill', 'rgba(71, 85, 105, 0.85)');
- path.setAttribute('stroke', 'rgba(148, 163, 184, 0.3)');
- path.setAttribute('stroke-width', '1.5');
-
- svg.appendChild(path);
- ship.appendChild(svg);
+ const info = document.createElement('div');
+ info.className = 'battleship-info';
+
+ const labelGroup = document.createElement('div');
+ labelGroup.className = 'battleship-label-group';
+
+ const codeBadge = document.createElement('span');
+ codeBadge.className = 'battleship-code';
+ codeBadge.textContent = nominal.code;
+ labelGroup.appendChild(codeBadge);
+
+ const title = document.createElement('span');
+ title.className = 'entry-title';
+ title.textContent = nominal.description;
+ labelGroup.appendChild(title);
+
+ const infoIcon = createInfoIcon();
+ infoIcon.setAttribute('aria-hidden', 'true');
+ infoIcon.setAttribute('focusable', 'false');
+ labelGroup.appendChild(infoIcon);
+
+ info.appendChild(labelGroup);
+
+ const valueBadge = document.createElement('span');
+ valueBadge.className = 'entry-value-badge';
+ valueBadge.textContent = formatCurrency(nominal.value);
+ info.appendChild(valueBadge);
+
+ ship.appendChild(info);
const card = document.createElement('div');
card.className = 'entry-card';
+ card.setAttribute('data-ship-variant', variant);
+
+ const hullSvg = createHullSVG(variant);
+ card.appendChild(hullSvg);
const tooltip = document.createElement('div');
tooltip.className = 'entry-tooltip';
@@ -291,33 +430,10 @@ Expenses (Operational & Financing)
card.appendChild(tooltip);
- const topRow = document.createElement('div');
- topRow.className = 'entry-content';
-
- const textContainer = document.createElement('div');
- textContainer.className = 'entry-text';
-
- const headingRow = document.createElement('div');
- headingRow.className = 'entry-heading';
-
- const title = document.createElement('p');
- title.className = 'entry-title';
- title.textContent = nominal.description;
- headingRow.appendChild(title);
- headingRow.appendChild(createInfoIcon());
- textContainer.appendChild(headingRow);
-
- const valueDisplay = document.createElement('p');
- valueDisplay.className = 'entry-value';
- valueDisplay.textContent = formatCurrency(nominal.value);
- textContainer.appendChild(valueDisplay);
-
- topRow.appendChild(textContainer);
- card.appendChild(topRow);
-
const pinWrapper = document.createElement('div');
pinWrapper.className = 'entry-pin-wrapper';
- const pinGrid = createPinGrid(nominal.value);
+ pinWrapper.setAttribute('data-pin-capacity', String(pinCapacity));
+ const pinGrid = createPinGrid(nominal.value, pinCapacity);
if (pinGrid) {
pinWrapper.appendChild(pinGrid);
}
@@ -377,6 +493,16 @@ Expenses (Operational & Financing)
rawData.assets.push(depreciationItem);
}
+ Object.values(rawData).forEach((items) => {
+ items.forEach((item) => {
+ const maxPins = item.values.reduce((max, value) => {
+ const { totalPinCount } = evaluatePinBreakdown(value);
+ return Math.max(max, totalPinCount);
+ }, 0);
+ item.pinCapacity = maxPins;
+ });
+ });
+
const yearlyData = years.map((_, index) => {
const yearEntry = {
year: START_YEAR + index,
@@ -393,6 +519,7 @@ Expenses (Operational & Financing)
code: item.code,
description: item.description,
value: item.values[index] || 0,
+ pinCapacity: item.pinCapacity || 0,
}));
const totalForYear = nominalsForYear.reduce((sum, nominal) => sum + nominal.value, 0);
diff --git a/static/projections/projections.css b/static/projections/projections.css
index 308533310..59586a296 100644
--- a/static/projections/projections.css
+++ b/static/projections/projections.css
@@ -208,11 +208,13 @@ body.page-body::selection {
.panel {
position: relative;
- border-radius: 12px;
+ border-radius: 18px;
overflow: hidden;
- padding: 0.5rem;
+ padding: 0.9rem;
min-height: 140px;
display: flex;
+ border: 4px solid rgba(15, 23, 42, 0.85);
+ box-shadow: 0 16px 26px rgba(15, 23, 42, 0.45);
}
.panel-overlay {
@@ -220,16 +222,16 @@ body.page-body::selection {
inset: 0;
background-image: linear-gradient(
to right,
- rgba(255, 255, 255, 0.07) 1px,
+ rgba(255, 255, 255, 0.08) 1px,
transparent 1px
),
linear-gradient(
to bottom,
- rgba(255, 255, 255, 0.07) 1px,
+ rgba(255, 255, 255, 0.08) 1px,
transparent 1px
);
- background-size: 1rem 1rem;
- opacity: 0.45;
+ background-size: 1.4rem 1.4rem;
+ opacity: 0.35;
pointer-events: none;
}
@@ -289,10 +291,22 @@ body.page-body::selection {
flex: 1;
min-height: 0;
overflow-y: auto;
- padding-right: 0.25rem;
- display: flex;
- flex-direction: column;
- gap: 0.3rem;
+ padding: 1.3rem 1.4rem 1.4rem;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 1.4rem;
+ align-content: start;
+ border-radius: 20px;
+ border: 3px solid rgba(15, 23, 42, 0.8);
+ background-color: rgba(148, 178, 208, 0.28);
+ background-image: radial-gradient(
+ rgba(15, 23, 42, 0.85) 20%,
+ transparent 22%
+ ),
+ radial-gradient(rgba(15, 23, 42, 0.35) 20%, transparent 22%);
+ background-size: 36px 36px;
+ background-position: 0 0, 18px 18px;
+ box-shadow: inset 0 10px 24px rgba(15, 23, 42, 0.4);
}
.panel-placeholder {
@@ -356,69 +370,173 @@ body.page-body::selection {
overflow: visible;
}
+
+
.battleship-container {
position: relative;
- margin-bottom: 0.25rem;
- min-height: 40px;
+ min-height: 0;
+ padding: 1rem 1.1rem;
+ border-radius: 18px;
+ border: 3px solid rgba(15, 23, 42, 0.78);
+ background: linear-gradient(165deg, rgba(37, 54, 81, 0.92), rgba(15, 23, 42, 0.92));
+ box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.35), 0 12px 22px rgba(15, 23, 42, 0.55);
+ display: grid;
+ grid-template-columns: minmax(160px, 0.95fr) minmax(200px, 1fr);
+ gap: 1rem;
+ align-items: center;
+ isolation: isolate;
+ --hull-width: 260px;
+ --hull-height: 80px;
+ --peg-scale: 1;
}
-.battleship-shape {
+.battleship-container::before {
+ content: "";
position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
+ inset: 0.55rem;
+ border-radius: 14px;
+ border: 1px dashed rgba(148, 163, 184, 0.35);
pointer-events: none;
- z-index: 0;
- opacity: 0;
+ opacity: 0.75;
+}
+
+.battleship-container.ship-patrol {
+ --hull-width: 200px;
+ --hull-height: 64px;
+ --peg-scale: 0.85;
+}
+
+.battleship-container.ship-submarine {
+ --hull-width: 230px;
+ --hull-height: 70px;
+ --peg-scale: 0.9;
+}
+
+.battleship-container.ship-destroyer {
+ --hull-width: 260px;
+ --hull-height: 80px;
+ --peg-scale: 1;
+}
+
+.battleship-container.ship-battleship {
+ --hull-width: 300px;
+ --hull-height: 90px;
+ --peg-scale: 1.05;
+}
+
+.battleship-container.ship-carrier {
+ --hull-width: 330px;
+ --hull-height: 96px;
+ --peg-scale: 1.1;
+}
+
+.battleship-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ align-items: flex-start;
+ z-index: 2;
+}
+
+.battleship-label-group {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+.battleship-code {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.2rem 0.55rem;
+ font-size: 0.6rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ border-radius: 999px;
+ background: rgba(15, 23, 42, 0.8);
+ border: 1px solid rgba(148, 163, 184, 0.4);
+ color: #e2e8f0;
+ font-weight: 700;
+ box-shadow: inset 0 0 0 1px rgba(30, 41, 59, 0.55);
+}
+
+.entry-title {
+ margin: 0;
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: #f8fafc;
+ max-width: 220px;
+ line-height: 1.25;
+}
+
+.info-icon {
+ width: 14px;
+ height: 14px;
+ color: rgba(148, 163, 184, 0.75);
+ flex-shrink: 0;
+ transition: color 0.2s ease;
+}
+
+.battleship-container:hover .info-icon,
+.battleship-container:focus-within .info-icon {
+ color: #fbbf24;
+}
+
+.entry-value-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.28rem 0.7rem;
+ border-radius: 999px;
+ background: rgba(251, 191, 36, 0.18);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+ color: #fde68a;
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
}
.entry-card {
position: relative;
- background: rgba(71, 85, 105, 0.85);
- border: 1.5px solid rgba(148, 163, 184, 0.3);
- padding: 0.5rem 0.6rem;
- transition: transform 0.2s ease, border-color 0.2s ease;
+ width: min(100%, var(--hull-width));
+ height: var(--hull-height);
+ display: flex;
+ align-items: center;
+ justify-content: center;
z-index: 1;
- clip-path: polygon(
- 0% 35%,
- 2% 32%,
- 4% 28%,
- 7% 25%,
- 10% 22%,
- 15% 18%,
- 20% 15%,
- 100% 15%,
- 100% 85%,
- 20% 85%,
- 15% 82%,
- 10% 78%,
- 7% 75%,
- 4% 72%,
- 2% 68%,
- 0% 65%
- );
-}
-
-.entry-card:hover,
-.entry-card:focus-within {
- transform: translateY(-1px);
- border-color: rgba(251, 191, 36, 0.5);
+}
+
+.entry-card svg.battleship-hull {
+ width: 100%;
+ height: 100%;
+ filter: drop-shadow(0 12px 18px rgba(15, 23, 42, 0.6));
+}
+
+.entry-card::after {
+ content: "";
+ position: absolute;
+ inset: 18% 16%;
+ border-radius: 999px;
+ background: rgba(148, 163, 184, 0.08);
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ box-shadow: inset 0 0 18px rgba(15, 23, 42, 0.6);
+ pointer-events: none;
}
.entry-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
- transform: translate(-50%, -4px);
- padding: 0.4rem 0.5rem;
- background: rgba(15, 23, 42, 0.95);
+ transform: translate(-50%, -6px);
+ padding: 0.45rem 0.6rem;
+ background: rgba(15, 23, 42, 0.97);
color: #fff;
border-radius: 6px;
- font-size: 0.65rem;
+ font-size: 0.68rem;
width: max-content;
- max-width: 200px;
- box-shadow: 0 12px 20px rgba(15, 23, 42, 0.45);
+ max-width: 220px;
+ box-shadow: 0 12px 20px rgba(15, 23, 42, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
@@ -438,7 +556,7 @@ body.page-body::selection {
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
- border-color: rgba(15, 23, 42, 0.95) transparent transparent transparent;
+ border-color: rgba(15, 23, 42, 0.97) transparent transparent transparent;
}
.entry-tooltip-title {
@@ -457,111 +575,71 @@ body.page-body::selection {
font-family: "JetBrains Mono", "Fira Code", monospace;
}
-.entry-content {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 0.5rem;
-}
-
-.entry-text {
- flex: 1;
-}
-
-.entry-heading {
+.entry-pin-wrapper {
+ position: absolute;
+ inset: 24% 14%;
display: flex;
align-items: center;
- gap: 0.25rem;
- margin-bottom: 0.15rem;
-}
-
-.entry-title {
- margin: 0;
- font-size: 0.65rem;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- font-weight: 700;
- color: #fff;
-}
-
-.info-icon {
- width: 13px;
- height: 13px;
- color: var(--text-muted);
- flex-shrink: 0;
- transition: color 0.2s ease;
-}
-
-.battleship-container:hover .info-icon,
-.battleship-container:focus-within .info-icon {
- color: var(--text-bright);
-}
-
-.entry-value {
- margin: 0;
- font-size: 0.75rem;
- font-weight: 600;
- color: var(--text-bright);
-}
-
-.entry-pin-wrapper {
- margin-top: 0.3rem;
- background: rgba(15, 23, 42, 0.45);
- border-radius: 6px;
- padding: 0.3rem;
+ justify-content: center;
+ pointer-events: none;
+ z-index: 2;
}
.pin-grid {
- display: flex;
- flex-wrap: wrap;
- gap: 0.2rem;
- min-height: 0.6rem;
+ display: grid;
+ grid-template-columns: repeat(var(--pin-grid-columns, 4), minmax(0, 1fr));
+ grid-template-rows: repeat(var(--pin-grid-rows, 2), minmax(0, 1fr));
+ gap: calc(0.18rem * var(--peg-scale, 1));
+ width: 100%;
+ height: 100%;
+ align-content: center;
+ justify-items: center;
+ pointer-events: auto;
}
.pin-cell {
- width: 0.6rem;
- height: 0.6rem;
+ width: calc(0.34rem * var(--peg-scale, 1));
+ height: calc(0.34rem * var(--peg-scale, 1));
border-radius: 999px;
- border: 1px solid rgba(148, 163, 184, 0.4);
- display: flex;
- align-items: center;
- justify-content: center;
+ border: 1px solid rgba(148, 163, 184, 0.45);
+ background: rgba(15, 23, 42, 0.55);
+ display: grid;
+ place-items: center;
}
.pin-dot {
- width: 100%;
- height: 100%;
+ width: 65%;
+ height: 65%;
border-radius: 999px;
}
.pin-dot.pin-ten.positive {
background: #f8fafc;
+ box-shadow: 0 0 0 1px rgba(226, 232, 240, 0.6);
}
.pin-dot.pin-ten.negative {
- background: rgba(248, 113, 113, 0.85);
-}
-
-.pin-dot.pin-hundred {
- background: #facc15;
+ background: rgba(239, 68, 68, 0.9);
+ box-shadow: 0 0 0 1px rgba(185, 28, 28, 0.7);
}
.pin-dot.pin-hundred.positive {
- box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.6);
+ background: rgba(34, 197, 94, 0.95);
+ box-shadow: 0 0 0 1px rgba(21, 128, 61, 0.7);
}
.pin-dot.pin-hundred.negative {
- box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.65);
- opacity: 0.85;
+ background: rgba(185, 28, 28, 0.9);
+ box-shadow: 0 0 0 1px rgba(153, 27, 27, 0.75);
}
.pin-more {
font-size: 0.6rem;
- color: var(--text-muted);
+ color: rgba(226, 232, 240, 0.85);
align-self: center;
- margin-left: 0.2rem;
}
+
.sr-only {
position: absolute;
width: 1px;
@@ -601,6 +679,20 @@ body.page-body::selection {
width: 100%;
height: auto;
}
+
+ .panel-entries {
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ }
+
+ .battleship-container {
+ grid-template-columns: 1fr;
+ text-align: center;
+ --hull-width: min(280px, 90vw);
+ }
+
+ .battleship-info {
+ align-items: center;
+ }
}
@media (max-width: 720px) {