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) {