Hugdrigo's picture
Upload 3 files
95428f8 verified
Raw History Blame Contribute Delete
51.7 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#10233b">
<meta name="description" content="ElasticityLab: an offline-first, illustrative pricing strategy and demand elasticity simulator.">
<title>ElasticityLab | Pricing Intelligence Studio</title>
<style>
:root{color-scheme:light;--page:#f3f5f9;--paper:#fff;--ink:#17263d;--sub:#66768c;--muted:#8896a8;--border:#e5eaf1;--soft:#f6f8fc;--navy:#101e36;--blue:#4268e7;--mint:#1eaf9b;--amber:#ecab48;--red:#d65f6d;--shadow:0 12px 38px rgba(29,48,80,.045);font:13px/1.54 Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif}
body.dark{color-scheme:dark;--page:#0c1626;--paper:#17263a;--ink:#eef3fa;--sub:#afbed0;--muted:#8e9fb1;--border:#304054;--soft:#213247;--blue:#94aaff;--mint:#69ddc7;--amber:#efbd75;--red:#f0929c;--shadow:none}
*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--ink)}button,input,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.5;cursor:not-allowed}button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #6ee2cd;outline-offset:2px}a{color:var(--blue)}.shell{display:grid;grid-template-columns:238px minmax(0,1fr);min-height:100vh}.side{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;padding:28px 16px 18px;background:radial-gradient(circle at -25% -10%,#2d557d 0,transparent 36%),linear-gradient(155deg,#132940 0,#0f1e35 65%,#112537 100%);color:#e4ecf9;z-index:5}.brand{display:flex;align-items:center;gap:11px;margin:0 6px 39px}.logo{width:41px;height:41px;border-radius:12px;background:linear-gradient(135deg,#b6f5db,#4dc5c7);display:grid;place-items:center;color:#0b3e4d;font-weight:1000;font-size:23px;box-shadow:0 5px 28px #55bba344}.brand strong{display:block;font-size:17px;letter-spacing:-.7px}.brand strong em{font-style:normal;color:#8ee6d2}.brand small{display:block;color:#92aabe;font-size:8px;letter-spacing:1.3px;font-weight:900}.side-label{font-size:9px;letter-spacing:2px;font-weight:900;color:#829caf;margin:0 11px 14px}.nav{display:grid;gap:7px}.nav button{text-align:left;padding:12px 12px;background:none;border:1px solid transparent;border-radius:10px;color:#aabed0;font-weight:750;font-size:11px;display:flex;align-items:center;gap:13px}.nav button span:first-child{font-size:17px;font-weight:400;width:20px}.nav button:hover{background:#223d59;color:#fff}.nav button.active{background:#244663;border-color:#375c7a;color:#fff}.side-bottom{margin-top:auto;border-top:1px solid #304a63;padding:18px 10px 0;color:#9db2c4;font-size:10px}.status{display:inline-block;background:#19544e;border:1px solid #367b72;color:#9df1d8;border-radius:25px;font-size:9px;letter-spacing:.5px;font-weight:900;padding:5px 9px}.side-bottom p{margin:12px 0 6px}.main{min-width:0;width:100%;max-width:1570px;margin:auto;padding:25px clamp(16px,3vw,47px) 57px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:20px}.crumb{font-size:10px;font-weight:900;letter-spacing:1.5px;color:var(--sub)}.crumb strong{color:var(--ink)}.topactions{display:flex;align-items:center;gap:8px}.smallbtn,.btn{border:1px solid var(--border);background:var(--paper);color:var(--ink);border-radius:9px;padding:9px 12px;font-size:11px;font-weight:800;white-space:nowrap}.smallbtn:hover,.btn:hover{border-color:var(--mint)}.smallbtn.iconbtn{height:35px;width:35px;padding:0;font-size:17px}.owner{background:#1d4965;color:#8af0dc;border:1px solid #38816f;border-radius:50%;width:35px;height:35px;display:grid;place-items:center;font-size:9px;font-weight:900}.tag{color:var(--mint);border:1px solid var(--border);background:var(--soft);padding:5px 9px;border-radius:18px;font-size:9px;font-weight:900}.btn.primary{background:var(--blue);color:#fff;border-color:var(--blue)}body.dark .btn.primary{background:#6587ee;color:#101d33}.btn.mint{background:var(--mint);border-color:var(--mint);color:#fff}body.dark .btn.mint{color:#0c2f39}.btn.danger{color:var(--red);border-color:var(--red)}.btn.ghost{background:transparent}.eyebrow{font-size:9px;font-weight:950;letter-spacing:1.8px;color:var(--mint)}.view{display:none}.view.active{display:block}.hero{border-radius:20px;background:linear-gradient(111deg,#152b51 0,#214e70 63%,#27827f);min-height:193px;padding:26px 31px;display:flex;align-items:center;justify-content:space-between;gap:24px;color:#fff;overflow:hidden;position:relative}.hero:before{content:"";position:absolute;right:-40px;top:-130px;width:360px;height:360px;border-radius:50%;border:1px solid #ffffff2c;box-shadow:0 0 0 48px #ffffff09,0 0 0 100px #ffffff05;pointer-events:none}.hero .eyebrow{color:#8af5db}.hero h1{font-size:clamp(26px,2.7vw,36px);line-height:1.17;letter-spacing:-1.2px;margin:8px 0 10px}.hero p{font-size:11.5px;color:#c7dfef;max-width:535px;margin:0}.hero .actions{display:flex;gap:9px;flex-wrap:wrap;margin-top:19px}.hero .btn{background:#eafff8;color:#175d62;border-color:#b5ebe0}.hero .btn.outline{background:#ffffff16;color:#fff;border-color:#ffffff4c}.hero-stat{min-width:207px;background:#ffffff15;border:1px solid #ffffff35;border-radius:16px;padding:17px 18px;position:relative;z-index:1}.hero-stat small{display:block;color:#cae2ee;font-size:10px}.hero-stat strong{font-size:31px;letter-spacing:-1.4px;display:block;margin:4px 0}.hero-stat span{color:#97f0d1;font-size:10px;font-weight:800}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:13px;margin:17px 0}.metric,.card{min-width:0;background:var(--paper);border:1px solid var(--border);border-radius:15px;box-shadow:var(--shadow)}.metric{padding:17px 18px}.metric-top{display:flex;justify-content:space-between;gap:5px;color:var(--sub);font-size:10px;font-weight:790}.metric-top span{font-size:16px;color:var(--mint)}.metric strong{display:block;font-size:clamp(22px,2.3vw,30px);letter-spacing:-1.2px;margin:7px 0 2px;white-space:nowrap}.metric small{color:var(--sub);font-size:10px}.metric small b{color:var(--mint)}.section-title{display:flex;align-items:end;justify-content:space-between;gap:13px;margin:28px 0 13px}.section-title h2{font-size:17px;letter-spacing:-.4px;margin:0}.section-title p{color:var(--sub);font-size:11px;margin:4px 0 0}.section-title .tools{display:flex;gap:7px;flex-wrap:wrap}.grid2{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(300px,1fr);gap:15px}.gridhalf{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.card{padding:19px}.card-head{display:flex;justify-content:space-between;gap:9px;align-items:flex-start;margin-bottom:15px}.card-head h3{font-size:13px;margin:0}.card-head p{font-size:10px;color:var(--sub);margin:4px 0 0}.pill{display:inline-block;white-space:nowrap;color:var(--mint);font-size:9px;font-weight:900;background:var(--soft);border:1px solid var(--border);padding:5px 8px;border-radius:7px}.plot{display:block;width:100%;max-height:295px;overflow:visible}.plot text{fill:var(--sub);font-size:9px;font-family:inherit}.legend{display:flex;flex-wrap:wrap;gap:15px;color:var(--sub);font-size:10px;margin-top:11px}.legend i{display:inline-block;width:9px;height:9px;border-radius:3px;background:var(--blue);margin-right:5px}.legend .green i{background:var(--mint)}.legend .gold i{background:var(--amber)}.barline{margin-bottom:17px}.barline:last-child{margin-bottom:0}.barline-head{display:flex;justify-content:space-between;gap:6px;font-size:11px;margin-bottom:8px}.barline-head span{color:var(--sub)}.bar-bg{height:8px;border-radius:99px;background:var(--soft);overflow:hidden}.bar-fill{height:100%;border-radius:99px;background:linear-gradient(90deg,#4c79f3,#40c1b3)}.insight{background:var(--soft);border:1px solid var(--border);border-radius:11px;padding:13px;margin-top:15px;font-size:10.5px;color:var(--sub)}.insight strong{color:var(--mint)}.intro{display:flex;align-items:end;justify-content:space-between;gap:16px;margin:8px 0 24px}.intro h1{font-size:29px;letter-spacing:-1px;margin:4px 0}.intro p{color:var(--sub);font-size:11.5px;margin:0;max-width:680px}.panel-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.micro{padding:13px;background:var(--soft);border:1px solid var(--border);border-radius:11px}.micro small{display:block;font-size:10px;color:var(--sub)}.micro strong{display:block;font-size:22px;margin:5px 0;letter-spacing:-.8px}.micro em{font-style:normal;color:var(--sub);font-size:9px}.controls{display:grid;gap:17px}.control>label{display:flex;justify-content:space-between;font-size:11px;font-weight:800;gap:8px;margin-bottom:8px}.control>label span{color:var(--mint)}input[type=range]{width:100%;accent-color:var(--mint)}.scenarios{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.scenario{min-width:0;border:1px solid var(--border);border-radius:12px;background:var(--paper);color:var(--ink);padding:15px;text-align:left}.scenario:hover{border-color:var(--mint)}.scenario.active{outline:2px solid var(--mint);background:var(--soft)}.scenario .ico{display:block;font-size:22px}.scenario strong{font-size:12px;display:block;margin:8px 0 4px}.scenario small{font-size:10px;color:var(--sub);line-height:1.5}.table-wrap{overflow:auto;max-width:100%}table{width:100%;border-collapse:collapse;white-space:nowrap;font-size:11px}th{text-align:left;text-transform:uppercase;letter-spacing:.8px;font-size:9px;color:var(--sub);padding:12px;border-bottom:1px solid var(--border)}td{padding:13px 12px;border-bottom:1px solid var(--border)}tr:last-child td{border-bottom:0}.num{text-align:right;font-variant-numeric:tabular-nums}.positive{color:var(--mint);font-weight:900}.negative{color:var(--red);font-weight:900}.actions{display:flex;gap:7px;flex-wrap:wrap}.tiny-btn{background:var(--soft);color:var(--ink);border:1px solid var(--border);border-radius:7px;padding:6px 8px;font-weight:800;font-size:10px}.tiny-btn:hover{border-color:var(--mint)}.tiny-btn.warn{color:var(--red)}.field{display:grid;gap:5px}.field label{font-weight:800;font-size:10px}.field input,.field select{width:100%;min-width:0;border:1px solid var(--border);border-radius:9px;padding:10px;background:var(--soft);color:var(--ink)}.fieldset{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field.wide{grid-column:1/-1}.empty{padding:25px;text-align:center;color:var(--sub)}.method{font-size:12px;color:var(--sub);line-height:1.8}.method h3{margin:18px 0 4px;color:var(--ink);font-size:12px}.method code{color:var(--ink);background:var(--soft);border:1px solid var(--border);border-radius:5px;padding:3px}.method ul{padding-left:19px}.callout{padding:12px 15px;background:var(--soft);border-left:3px solid var(--amber);border-radius:9px;font-size:11px;color:var(--sub)}.footer{margin-top:37px;border-top:1px solid var(--border);padding-top:14px;color:var(--sub);font-size:10px}.modal-cover{position:fixed;inset:0;background:#071628bd;z-index:30;display:none;align-items:center;justify-content:center;padding:16px}.modal-cover.open{display:flex}.modal{width:100%;max-width:540px;max-height:90vh;overflow:auto;border-radius:17px;background:var(--paper);border:1px solid var(--border);padding:24px}.modal h2{font-size:19px;margin:0 0 5px}.modal p{color:var(--sub);font-size:11px;margin:0 0 17px}.modal .actions{margin-top:18px;justify-content:flex-end}.toast{position:fixed;right:17px;bottom:18px;border-radius:10px;padding:11px 15px;background:#163747;color:#e9fffa;z-index:50;font-size:11px;font-weight:800;opacity:0;pointer-events:none;transition:opacity .18s}.toast.on{opacity:1}.mobile-nav{display:none}
@media(max-width:1110px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.scenarios{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:800px){.shell{display:block}.side{display:none}.main{padding:20px 14px 95px}.mobile-nav{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--paper);border-top:1px solid var(--border);padding:5px 3px max(5px,env(safe-area-inset-bottom));display:grid;grid-template-columns:repeat(5,1fr)}.mobile-nav button{background:none;border:0;color:var(--sub);font-size:9px;font-weight:850;padding:7px 0;display:grid;place-items:center}.mobile-nav button i{font-size:18px;font-style:normal}.mobile-nav button.active{color:var(--mint)}.hero{padding:23px;min-height:0}.hero-stat{display:none}.gridhalf{grid-template-columns:1fr}.topactions .compact{display:none}.crumb{font-size:9px}.intro h1{font-size:25px}.card{padding:15px}}
@media(max-width:460px){.metric{padding:13px}.metric strong{font-size:22px}.metrics{gap:9px}.hero h1{font-size:25px}.scenarios{gap:9px}.scenario{padding:10px}.scenario strong{font-size:11px}.panel-row{grid-template-columns:1fr 1fr}.panel-row .micro:last-child{grid-column:1/-1}.fieldset{grid-template-columns:1fr}.field.wide{grid-column:auto}.topbar .owner{display:none}}
</style>
</head>
<body>
<div class="shell">
<aside class="side">
<div class="brand"><div class="logo">β†—</div><div><strong>Elasticity<em>Lab</em></strong><small>PRICING INTELLIGENCE</small></div></div>
<div class="side-label">WORKSPACE</div>
<nav class="nav" aria-label="Main navigation" id="sideNav">
<button class="active" data-view="overview"><span>β—«</span> Overview</button>
<button data-view="lab"><span>⌁</span> Price laboratory</button>
<button data-view="portfolio"><span>β–¦</span> Product portfolio</button>
<button data-view="scenarios"><span>β—‡</span> Scenario planner</button>
<button data-view="method"><span>β—Ž</span> Methodology</button>
</nav>
<div class="side-bottom"><span class="status">● OFFLINE READY</span><p>Interactive pricing models<br>for thoughtful business decisions.</p><span>Β© 2026 Hugdrigo Β· Project 089</span></div>
</aside>
<main class="main">
<div class="topbar"><div class="crumb">HUGDRIGO / <strong>ELASTICITYLAB</strong> / <span id="crumbPage">OVERVIEW</span></div><div class="topactions"><span class="tag compact">ILLUSTRATIVE DATA</span><button class="smallbtn compact" data-action="export-json">⇩ Backup</button><button class="smallbtn iconbtn" data-action="theme" title="Toggle light / dark" aria-label="Toggle theme">☾</button><div class="owner" title="Hugdrigo">HG</div></div></div>
<section class="view active" id="view-overview">
<div class="hero"><div><div class="eyebrow">β—ˆ REVENUE STRATEGY / PROJECT 089</div><h1>Find the price behind<br>better performance.</h1><p>Explore demand elasticity, contribution margins and price recommendations in one transparent decision workspace.</p><div class="actions"><button class="btn" data-view="lab">Launch price lab β†—</button><button class="btn outline" data-action="export-csv">Export analysis ↓</button></div></div><div class="hero-stat"><small>Portfolio profit opportunity</small><strong id="heroLift">$0</strong><span id="heroMeta">relative to current pricing</span></div></div>
<div class="metrics" id="overviewMetrics"></div>
<div class="section-title"><div><h2>Commercial performance</h2><p>Current vs. modeled recommended prices Β· monthly illustration</p></div><div class="tools"><button class="smallbtn" data-view="lab">Explore pricing β†—</button></div></div>
<div class="grid2"><div class="card"><div class="card-head"><div><h3>Gross profit by product</h3><p>Compare profit contribution at current and recommended prices.</p></div><span class="pill">USD / MONTH</span></div><div id="profitBars"></div><div class="legend"><span><i></i> Current</span><span class="green"><i></i> Optimized</span></div></div><div class="card"><div class="card-head"><div><h3>Portfolio opportunities</h3><p>Products with the largest modeled monthly uplift.</p></div><span class="pill">RANKED</span></div><div id="opportunityBars"></div><div class="insight"><strong>About these estimates</strong><br>Recommendations maximize a simple constant-elasticity gross profit model. Real customer responses may differ.</div></div></div>
<div class="section-title"><div><h2>Recommended pricing</h2><p>Model results for active products under your selected scenario</p></div></div><div class="card table-wrap" id="overviewTable"></div>
</section>
<section class="view" id="view-lab"><div class="intro"><div><div class="eyebrow">ANALYSIS / INTERACTIVE MODEL</div><h1>Price laboratory</h1><p>Inspect the entire price–demand–profit relationship for one product. Adjust business constraints and instantly recompute the strategy.</p></div><button class="btn" data-action="export-csv">Export results ↓</button></div>
<div class="gridhalf"><div class="card"><div class="card-head"><div><h3>Selected product</h3><p>Use your portfolio to switch between price curves.</p></div><span class="pill">LIVE MODEL</span></div><div class="field"><label for="labProduct">Product / service</label><select id="labProduct"></select></div><div class="panel-row" id="labStats" style="margin-top:17px"></div></div><div class="card"><div class="card-head"><div><h3>Pricing constraints</h3><p>Changes update recommended prices across the portfolio.</p></div><span class="pill">EDITABLE</span></div><div class="controls"><div class="control"><label for="maxChange">Maximum price change <span id="maxChangeValue"></span></label><input id="maxChange" data-setting="maxMove" type="range" min="5" max="50" step="1"></div><div class="control"><label for="marginFloor">Minimum gross margin <span id="marginFloorValue"></span></label><input id="marginFloor" data-setting="marginFloor" type="range" min="0" max="60" step="1"></div></div></div></div>
<div class="section-title"><div><h2>Price sensitivity</h2><p>Model demand, revenue, and gross profit across allowable prices</p></div></div><div class="grid2"><div class="card"><div class="card-head"><div><h3 id="curveTitle">Price response curve</h3><p>Gross profit is optimized subject to your constraints.</p></div><span class="pill">PRICE β†’ PROFIT</span></div><div id="curveChart"></div><div class="legend"><span><i></i> Gross profit</span><span class="green"><i></i> Revenue</span><span class="gold"><i></i> Current price</span></div></div><div class="card"><div class="card-head"><div><h3>Recommendation details</h3><p>Transparent inputs and side-by-side modeled outputs.</p></div><span class="pill">USD / MONTH</span></div><div id="labDetail"></div></div></div></section>
<section class="view" id="view-portfolio"><div class="intro"><div><div class="eyebrow">CATALOG / PRICING INPUTS</div><h1>Product portfolio</h1><p>Add, edit, archive, and restore products. Model results update whenever an input changes. All data stays in your browser.</p></div><div class="actions"><button class="btn" data-action="export-json">Backup JSON ↓</button><button class="btn primary" data-action="new-product">+ Add product</button></div></div><div class="metrics" id="portfolioMetrics"></div><div class="section-title"><div><h2>Active products</h2><p>Prices, estimated units, elasticity and modeled profit</p></div><div class="tools"><button class="smallbtn" data-action="import-json">↑ Restore backup</button><button class="smallbtn" data-action="export-csv">↓ CSV</button></div></div><div class="card table-wrap" id="portfolioTable"></div><div class="section-title"><div><h2>Archived products</h2><p>Restore previous entries without losing their settings.</p></div></div><div class="card table-wrap" id="archiveTable"></div></section>
<section class="view" id="view-scenarios"><div class="intro"><div><div class="eyebrow">MODELING / STRESS TEST</div><h1>Scenario planner</h1><p>Stress-test pricing recommendations against shifting customer demand, higher supplier costs, and changing sales effectiveness.</p></div><button class="btn" data-action="export-csv">Export scenario ↓</button></div><div class="scenarios" id="presetCards"></div><div class="section-title"><div><h2>Adjust the business environment</h2><p>Slider changes apply across your active product portfolio</p></div></div><div class="grid2"><div class="card"><div class="card-head"><div><h3>Scenario drivers</h3><p>Percentage changes from illustrative baseline conditions.</p></div><span class="pill">CUSTOMIZABLE</span></div><div class="controls"><div class="control"><label for="demandInput">Market demand shock <span id="demandLabel"></span></label><input type="range" id="demandInput" data-scenario="demand" min="-40" max="40" step="1"></div><div class="control"><label for="costInput">Unit cost inflation <span id="costLabel"></span></label><input type="range" id="costInput" data-scenario="cost" min="-15" max="35" step="1"></div><div class="control"><label for="marketingInput">Sales channel lift <span id="marketingLabel"></span></label><input type="range" id="marketingInput" data-scenario="channel" min="-20" max="50" step="1"></div></div><div class="insight">Demand and channel adjustments scale estimated unit demand. Cost inflation adjusts variable unit cost. These are independent illustrative assumptions, not forecasts.</div></div><div class="card"><div class="card-head"><div><h3>Estimated scenario outcome</h3><p>At current prices versus constrained optimization.</p></div><span class="pill">PORTFOLIO TOTAL</span></div><div class="panel-row" id="scenarioMetrics"></div><div id="scenarioInsight" class="insight"></div></div></div><div class="section-title"><div><h2>Uncertainty explorer</h2><p>600 seeded simulations around the selected scenario Β· illustrative ranges</p></div><div class="tools"><button class="smallbtn" data-action="rerun">↻ Rerun simulation</button></div></div><div class="grid2"><div class="card"><div class="card-head"><div><h3>Modeled monthly gross profit distribution</h3><p>Deterministic random seed for reproducibility.</p></div><span class="pill">600 RUNS</span></div><div id="histogram"></div></div><div class="card"><div class="card-head"><div><h3>Simulation percentiles</h3><p>Illustrative 10th / median / 90th percentile outcomes.</p></div><span class="pill">SCENARIO RANGE</span></div><div id="percentiles"></div><div class="callout" style="margin-top:12px">These intervals are generated from assumed volatility, not calibrated probabilities or investment advice.</div></div></div></section>
<section class="view" id="view-method"><div class="intro"><div><div class="eyebrow">REFERENCE / TRANSPARENCY</div><h1>Methodology</h1><p>Understand the assumptions behind the pricing, optimization, and uncertainty calculations.</p></div><button class="btn" data-action="reset">β†Ί Reset demo</button></div><div class="gridhalf"><div class="card method"><h3>1 Β· Constant-elasticity demand</h3><p>For each product, modeled unit demand at a trial price is:</p><p><code>Q(p) = Qβ‚€ Γ— (p / pβ‚€)^(-Ξ΅) Γ— D Γ— C</code></p><p>Here, Qβ‚€ is baseline monthly demand, pβ‚€ the current price, Ξ΅ the assumed positive elasticity magnitude, D the market demand multiplier, and C the sales channel multiplier.</p><h3>2 Β· Profit and revenue</h3><p><code>Revenue = p Γ— Q(p)</code></p><p><code>Gross profit = (p βˆ’ variable unit cost) Γ— Q(p)</code></p><p>Fixed expenses, tax, competitor actions, churn, and price discrimination are not included.</p><h3>3 Β· Constraints and optimization</h3><p>The algorithm scans 1,001 evenly spaced prices between the current price Β± the allowed move, subject to a minimum gross margin threshold. It chooses the price yielding the greatest gross profit. When no price satisfies constraints, that product remains at its current price in portfolio totals and the exception is flagged.</p></div><div class="card method"><h3>4 Β· Scenario stress tests</h3><p>Demand shock and channel lift multiply estimated demand. Cost inflation adjusts unit variable cost. Each product is optimized again under the altered conditions.</p><h3>5 Β· Uncertainty simulation</h3><p>600 reproducible pseudo-random draws perturb modeled unit demand and unit costs. The application displays the 10th, 50th, and 90th percentile of optimized portfolio gross profit. These distributions are illustrative.</p><h3>6 Β· Data integrity and privacy</h3><ul><li>No external network requests, API keys, account connection, or tracking.</li><li>Changes are stored locally in browser storage where available.</li><li>JSON backups are validated before restoring.</li><li>CSV output protects against spreadsheet formula injection.</li><li>All initial products and results are fictional demonstration examples.</li></ul><h3>7 Β· Suitable uses and limits</h3><p>This is a decision-support teaching tool, not a calibrated forecasting service. Validate elasticities, costs, competitors, operational constraints, and willingness-to-pay using empirical information before commercial use.</p></div></div><div class="section-title"><div><h2>Workspace maintenance</h2><p>Keep full control of local demonstration data</p></div></div><div class="card"><div class="actions"><button class="btn" data-action="export-json">Download JSON backup</button><button class="btn" data-action="import-json">Restore JSON backup</button><button class="btn danger" data-action="reset">Reset all to demo</button></div><p class="insight">Reset replaces the local workspace only after confirmation. It does not change any Hugging Face repository or other projects.</p></div></section>
<footer class="footer">ElasticityLab Β· Project 089 Β· Hugdrigo Β· Offline-first demonstration Β· Not financial advice Β· Open-source MIT</footer>
</main></div>
<nav class="mobile-nav" aria-label="Mobile navigation" id="mobileNav"><button data-view="overview" class="active"><i>β—«</i>Home</button><button data-view="lab"><i>⌁</i>Lab</button><button data-view="portfolio"><i>β–¦</i>Products</button><button data-view="scenarios"><i>β—‡</i>Scenarios</button><button data-view="method"><i>β—Ž</i>About</button></nav>
<div class="modal-cover" id="modalCover" role="dialog" aria-modal="true" aria-labelledby="modalTitle"><form class="modal" id="productForm"><h2 id="modalTitle">Add product</h2><p>Enter illustrative monthly unit demand and unit economics. All values must be positive except unit cost.</p><div class="fieldset"><div class="field wide"><label for="productName">Product name</label><input id="productName" name="name" maxlength="72" required placeholder="Example: Cloud subscription"></div><div class="field"><label for="productSegment">Segment</label><select id="productSegment" name="segment"><option>Digital</option><option>Services</option><option>Consumer</option><option>Enterprise</option></select></div><div class="field"><label for="productPrice">Current unit price (USD)</label><input id="productPrice" type="number" name="price" min="0.01" max="10000000" step="0.01" required></div><div class="field"><label for="productCost">Variable unit cost (USD)</label><input id="productCost" type="number" name="cost" min="0" max="10000000" step="0.01" required></div><div class="field"><label for="productUnits">Monthly baseline units</label><input id="productUnits" type="number" name="units" min="1" max="10000000" step="1" required></div><div class="field wide"><label for="productElasticity">Elasticity magnitude, Ξ΅ (0.1–5)</label><input id="productElasticity" type="number" name="elasticity" min="0.1" max="5" step="0.05" required></div></div><div class="actions"><button class="btn" type="button" data-action="close-modal">Cancel</button><button class="btn primary" type="submit">Save product</button></div></form></div>
<input type="file" id="fileImport" accept="application/json,.json" hidden>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
'use strict';
(function(){
const STORAGE='hugdrigo_elasticitylab_v1';
const DEMO=[
{id:'p1',name:'Creator Pro Subscription',segment:'Digital',price:39,cost:9,units:1250,elasticity:1.45,archived:false},
{id:'p2',name:'Workflow Automation Pack',segment:'Digital',price:89,cost:22,units:590,elasticity:1.85,archived:false},
{id:'p3',name:'Strategy Consulting Sprint',segment:'Services',price:480,cost:172,units:135,elasticity:.9,archived:false},
{id:'p4',name:'Premium Learning Bundle',segment:'Consumer',price:149,cost:36,units:420,elasticity:2.1,archived:false},
{id:'p5',name:'Enterprise Analytics Seat',segment:'Enterprise',price:245,cost:67,units:230,elasticity:1.12,archived:false},
{id:'p6',name:'Business Insight Report',segment:'Services',price:65,cost:14,units:820,elasticity:1.7,archived:false}
];
const PRESETS={base:{demand:0,cost:0,channel:0,name:'Base case',emoji:'β—‰',desc:'Unchanged market conditions'},slump:{demand:-24,cost:8,channel:-5,name:'Demand slump',emoji:'β†˜',desc:'Weaker sales, rising costs'},squeeze:{demand:-8,cost:28,channel:0,name:'Cost squeeze',emoji:'β–°',desc:'Supplier-side pressure'},surge:{demand:22,cost:5,channel:12,name:'Growth surge',emoji:'β†—',desc:'Stronger demand and reach'}};
const fresh=()=>({version:1,products:DEMO.map(x=>({...x})),settings:{maxMove:30,marginFloor:15},scenario:{demand:0,cost:0,channel:0,preset:'base'},selected:'p1',seed:17089,theme:'light'});
const $=id=>document.getElementById(id);
const clamp=(x,a,b)=>Math.min(b,Math.max(a,x));
const isN=n=>typeof n==='number'&&Number.isFinite(n);
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const usd=(x,d=0)=>Number(x).toLocaleString('en-US',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d});
const fmt=(x,d=0)=>Number(x).toLocaleString('en-US',{maximumFractionDigits:d});
const pct=x=>(x>=0?'+':'')+fmt(x,1)+'%';
const demoData=()=>JSON.parse(JSON.stringify(fresh()));
function validProduct(p){return !!(p&&typeof p.id==='string'&&p.id.length<=80&&typeof p.name==='string'&&p.name.trim()&&p.name.length<=72&&['Digital','Services','Consumer','Enterprise'].includes(p.segment)&&isN(p.price)&&p.price>=.01&&p.price<=1e7&&isN(p.cost)&&p.cost>=0&&p.cost<=1e7&&isN(p.units)&&Number.isInteger(p.units)&&p.units>=1&&p.units<=1e7&&isN(p.elasticity)&&p.elasticity>=.1&&p.elasticity<=5&&typeof p.archived==='boolean');}
function checked(raw){if(!raw||raw.version!==1||!Array.isArray(raw.products)||raw.products.length>100||raw.products.length<1||!raw.products.every(validProduct))throw Error('Invalid products or backup format');const ids=raw.products.map(p=>p.id);if(new Set(ids).size!==ids.length)throw Error('Duplicate product identifiers');const t=raw.settings,s=raw.scenario;if(!t||!isN(t.maxMove)||t.maxMove<5||t.maxMove>50||!isN(t.marginFloor)||t.marginFloor<0||t.marginFloor>60)throw Error('Invalid settings');if(!s||!['demand','cost','channel'].every(k=>isN(s[k]))||s.demand< -40||s.demand>40||s.cost< -15||s.cost>35||s.channel< -20||s.channel>50)throw Error('Invalid scenario');return {version:1,products:raw.products.map(p=>({id:p.id,name:p.name.trim(),segment:p.segment,price:p.price,cost:p.cost,units:p.units,elasticity:p.elasticity,archived:p.archived})),settings:{maxMove:t.maxMove,marginFloor:t.marginFloor},scenario:{demand:s.demand,cost:s.cost,channel:s.channel,preset:Object.hasOwn(PRESETS,s.preset)?s.preset:'custom'},selected:typeof raw.selected==='string'?raw.selected:'',seed:isN(raw.seed)?clamp(Math.floor(raw.seed),1,2147483646):17089,theme:raw.theme==='dark'?'dark':'light'};}
let state=fresh();try{let raw=localStorage.getItem(STORAGE);if(raw)state=checked(JSON.parse(raw));}catch(e){console.warn('Local workspace unavailable, using demonstration data.');}
let currentView='overview',editing=null,toastTimer=null;
function persist(){try{localStorage.setItem(STORAGE,JSON.stringify(state));}catch(e){toast('Browser storage is unavailable; download a JSON backup.');}}
const active=()=>state.products.filter(p=>!p.archived);
const selected=()=>active().find(p=>p.id===state.selected)||active()[0]||null;
const factors=s=>({demand:1+s.demand/100,channel:1+s.channel/100,cost:1+s.cost/100});
function calc(p,price,sc=state.scenario){const f=factors(sc),q=p.units*Math.pow(price/p.price,-p.elasticity)*f.demand*f.channel,c=p.cost*f.cost;return {price,units:q,cost:c,revenue:q*price,profit:q*(price-c),margin:price>0?(price-c)/price:0};}
function optimize(p,sc=state.scenario,settings=state.settings){const lo=Math.max(.01,p.price*(1-settings.maxMove/100)),hi=p.price*(1+settings.maxMove/100),cost=p.cost*factors(sc).cost,minPrice=cost/(1-settings.marginFloor/100);if(Math.max(lo,minPrice)>hi+1e-8)return null;const start=Math.max(lo,minPrice),width=hi-start;let best=null;for(let i=0;i<=1000;i++){const r=calc(p,start+width*i/1000,sc);if(!best||r.profit>best.profit)best=r;}return best;}
function results(sc=state.scenario){return active().map(p=>{const cur=calc(p,p.price,sc),opt=optimize(p,sc);return {p,cur,opt,chosen:opt||cur,delta:opt?opt.profit-cur.profit:0};});}
function totals(rows=results()){return rows.reduce((a,r)=>({revenue:a.revenue+r.cur.revenue,profit:a.profit+r.cur.profit,optProfit:a.optProfit+r.chosen.profit,units:a.units+r.cur.units,excluded:a.excluded+(!r.opt?1:0)}),{revenue:0,profit:0,optProfit:0,units:0,excluded:0});}
function metric(label,value,subtitle,ico){return `<article class="metric"><div class="metric-top">${esc(label)}<span>${ico}</span></div><strong>${value}</strong><small>${subtitle}</small></article>`;}
function showView(v){if(!['overview','lab','portfolio','scenarios','method'].includes(v))return;currentView=v;document.querySelectorAll('.view').forEach(e=>e.classList.toggle('active',e.id==='view-'+v));document.querySelectorAll('[data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===v));$('crumbPage').textContent=({overview:'OVERVIEW',lab:'PRICE LAB',portfolio:'PRODUCTS',scenarios:'SCENARIOS',method:'METHODOLOGY'})[v];window.scrollTo({top:0,behavior:'instant'});render();}
function chartLine(values,accessory){const w=670,h=290,pad={t:23,r:25,b:36,l:62};let lo=Math.min(...values.flatMap(v=>[v.a,v.b]),0),hi=Math.max(...values.flatMap(v=>[v.a,v.b]),1);if(hi<=lo)hi=lo+1;const x=i=>pad.l+i/(values.length-1)*(w-pad.l-pad.r);const y=v=>pad.t+(hi-v)/(hi-lo)*(h-pad.t-pad.b);const path=k=>values.map((v,i)=>(i?'L':'M')+x(i).toFixed(2)+','+y(v[k]).toFixed(2)).join(' ');let grid='';for(let j=0;j<=4;j++){let yy=pad.t+(h-pad.t-pad.b)*j/4;grid+=`<line x1="${pad.l}" x2="${w-pad.r}" y1="${yy}" y2="${yy}" stroke="var(--border)"/><text x="${pad.l-11}" y="${yy+3}" text-anchor="end">${fmt(hi*(1-j/4)/1000,1)}k</text>`;}let labels='';for(let j=0;j<=4;j++){const i=Math.round((values.length-1)*j/4);labels+=`<text x="${x(i)}" y="${h-11}" text-anchor="middle">${usd(values[i].price,0)}</text>`;}let vx=accessory&&isN(accessory.price)?pad.l+(accessory.price-values[0].price)/(values.at(-1).price-values[0].price)*(w-pad.l-pad.r):null;return `<svg class="plot" viewBox="0 0 ${w} ${h}" role="img" aria-label="Gross profit and revenue curve across different unit prices">${grid}<path d="${path('a')}" stroke="var(--blue)" stroke-width="3" fill="none"/><path d="${path('b')}" stroke="var(--mint)" stroke-width="2.6" fill="none" stroke-dasharray="5 5"/>${vx===null?'':`<line x1="${vx}" x2="${vx}" y1="${pad.t}" y2="${h-pad.b}" stroke="var(--amber)" stroke-dasharray="4 5" stroke-width="2"/>`}${labels}<text x="${w/2}" y="${h-1}" text-anchor="middle">Unit price (USD)</text></svg>`;}
function renderOverview(){const rows=results(),t=totals(rows),lift=t.optProfit-t.profit; $('heroLift').textContent=usd(lift);$('heroMeta').textContent=(lift>=0?'+':'')+fmt(t.profit!==0?lift/Math.abs(t.profit)*100:0,1)+'% modeled gross profit lift';$('overviewMetrics').innerHTML=metric('Monthly revenue',usd(t.revenue),'At current prices','β–€')+metric('Gross profit',usd(t.profit),'Before fixed business expenses','β—ˆ')+metric('Optimized gross profit',usd(t.optProfit),'Under pricing constraints','β†—')+metric('Active products',fmt(rows.length),t.excluded?`${t.excluded} without feasible price`:'All products modeled','β–¦');const max=Math.max(1,...rows.map(r=>Math.max(Math.abs(r.cur.profit),Math.abs(r.chosen.profit))));$('profitBars').innerHTML=rows.length?rows.map(r=>`<div class="barline"><div class="barline-head"><strong>${esc(r.p.name)}</strong><span>${usd(r.chosen.profit)}</span></div><div class="bar-bg"><div class="bar-fill" style="width:${clamp(100*Math.max(0,r.cur.profit)/max,0,100)}%;background:var(--blue)"></div></div><div class="bar-bg" style="margin-top:4px"><div class="bar-fill" style="width:${clamp(100*Math.max(0,r.chosen.profit)/max,0,100)}%;background:var(--mint)"></div></div></div>`).join(''):'<div class="empty">No active products.</div>';const ranked=[...rows].sort((a,b)=>b.delta-a.delta),maxLift=Math.max(1,...ranked.map(r=>r.delta));$('opportunityBars').innerHTML=ranked.length?ranked.slice(0,5).map(r=>`<div class="barline"><div class="barline-head"><strong>${esc(r.p.name)}</strong><span>${usd(r.delta)}</span></div><div class="bar-bg"><div class="bar-fill" style="width:${r.delta/maxLift*100}%"></div></div></div>`).join(''):'<div class="empty">Add an active product.</div>';$('overviewTable').innerHTML=rows.length?`<table><thead><tr><th>Product</th><th class="num">Current price</th><th class="num">Recommended</th><th class="num">Current profit</th><th class="num">Modeled lift</th><th>Action</th></tr></thead><tbody>${rows.map(r=>`<tr><td><strong>${esc(r.p.name)}</strong><br><small style="color:var(--sub)">${esc(r.p.segment)}</small></td><td class="num">${usd(r.p.price,2)}</td><td class="num">${r.opt?usd(r.opt.price,2):'<span class="negative">Infeasible</span>'}</td><td class="num">${usd(r.cur.profit)}</td><td class="num positive">${usd(r.delta)}</td><td><button class="tiny-btn" data-action="inspect" data-id="${esc(r.p.id)}">Inspect β†—</button></td></tr>`).join('')}</tbody></table>`:'<div class="empty">No active products. Add one to begin.</div>';}
function renderLab(){let items=active();$('labProduct').innerHTML=items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected()?.id?'selected':''}>${esc(p.name)}</option>`).join('');$('labProduct').disabled=!items.length;$('maxChange').value=state.settings.maxMove;$('marginFloor').value=state.settings.marginFloor;$('maxChangeValue').textContent=state.settings.maxMove+'%';$('marginFloorValue').textContent=state.settings.marginFloor+'%';let p=selected();if(!p){$('labStats').innerHTML='<div class="empty">No active products</div>';$('curveChart').innerHTML='<div class="empty">Create a product to explore.</div>';$('labDetail').innerHTML='';return;}let cur=calc(p,p.price),opt=optimize(p);$('labStats').innerHTML=`<div class="micro"><small>Base demand</small><strong>${fmt(p.units)}</strong><em>Units / month</em></div><div class="micro"><small>Elasticity |Ξ΅|</small><strong>${fmt(p.elasticity,2)}</strong><em>Assumed response</em></div><div class="micro"><small>Variable cost</small><strong>${usd(p.cost)}</strong><em>Per unit</em></div>`;$('curveTitle').textContent=p.name+' Β· price response';const lo=Math.max(.01,p.price*(1-state.settings.maxMove/100)),hi=p.price*(1+state.settings.maxMove/100);const points=Array.from({length:81},(_,i)=>{const price=lo+(hi-lo)*i/80,r=calc(p,price);return {price,a:r.profit,b:r.revenue};});$('curveChart').innerHTML=chartLine(points,{price:p.price});const change=opt?100*(opt.price/p.price-1):0;const detailRows=[['Unit price',usd(cur.price,2),opt?usd(opt.price,2):'No feasible price'],['Units / month',fmt(cur.units),opt?fmt(opt.units):'β€”'],['Monthly revenue',usd(cur.revenue),opt?usd(opt.revenue):'β€”'],['Monthly gross profit',usd(cur.profit),opt?usd(opt.profit):'β€”'],['Gross margin',fmt(cur.margin*100,1)+'%',opt?fmt(opt.margin*100,1)+'%':'β€”']];$('labDetail').innerHTML=`<div class="table-wrap"><table><thead><tr><th>Metric</th><th class="num">Current</th><th class="num">Suggested</th></tr></thead><tbody>${detailRows.map(z=>`<tr><td>${z[0]}</td><td class="num">${z[1]}</td><td class="num">${z[2]}</td></tr>`).join('')}</tbody></table></div><div class="insight"><strong>${opt?`Suggested price movement: ${pct(change)}`:'No feasible recommendation'}</strong><br>${opt?'The model selects the highest gross profit within your price and margin guardrails.':'Try increasing the price movement limit or lowering the margin threshold.'}</div>`;}
function renderPortfolio(){const items=active(),arch=state.products.filter(p=>p.archived),t=totals();$('portfolioMetrics').innerHTML=metric('Active products',fmt(items.length),'In the current analysis','β–¦')+metric('Archived products',fmt(arch.length),'Recoverable records','β–’')+metric('Current monthly units',fmt(t.units),'Modeled for selected scenario','β—Ž')+metric('Revenue in model',usd(t.revenue),'At current unit prices','β—ˆ');$('portfolioTable').innerHTML=items.length?`<table><thead><tr><th>Product</th><th class="num">Price</th><th class="num">Unit cost</th><th class="num">Base units</th><th class="num">Elasticity</th><th class="num">Gross profit</th><th>Actions</th></tr></thead><tbody>${items.map(p=>`<tr><td><strong>${esc(p.name)}</strong><br><small style="color:var(--sub)">${esc(p.segment)}</small></td><td class="num">${usd(p.price,2)}</td><td class="num">${usd(p.cost,2)}</td><td class="num">${fmt(p.units)}</td><td class="num">${fmt(p.elasticity,2)}</td><td class="num">${usd(calc(p,p.price).profit)}</td><td><div class="actions"><button class="tiny-btn" data-action="edit" data-id="${esc(p.id)}">Edit</button><button class="tiny-btn warn" data-action="archive" data-id="${esc(p.id)}">Archive</button></div></td></tr>`).join('')}</tbody></table>`:'<div class="empty">No active products. Choose Add product to create your first record.</div>';$('archiveTable').innerHTML=arch.length?`<table><thead><tr><th>Product</th><th>Segment</th><th class="num">Price</th><th>Actions</th></tr></thead><tbody>${arch.map(p=>`<tr><td>${esc(p.name)}</td><td>${esc(p.segment)}</td><td class="num">${usd(p.price,2)}</td><td><button class="tiny-btn" data-action="restore" data-id="${esc(p.id)}">Restore</button></td></tr>`).join('')}</tbody></table>`:'<div class="empty">No archived products.</div>';}
function simulate(rows=results(),seed=state.seed){let out=[],r=seed>>>0;const uniform=()=>{r=(Math.imul(1664525,r)+1013904223)>>>0;return (r+.5)/4294967296};for(let i=0;i<600;i++){let total=0;const common=(uniform()+uniform()+uniform()+uniform()-2)*.20;for(let j=0;j<rows.length;j++){const item=rows[j],u=1+common+(uniform()+uniform()+uniform()+uniform()-2)*.25,c=1+(uniform()+uniform()-1)*.16;const q=item.chosen.units*Math.max(.01,u),cost=item.chosen.cost*Math.max(.01,c);total+=(item.chosen.price-cost)*q;}out.push(total);}return out.sort((a,b)=>a-b);}
function histogram(values){if(!values.length)return '<div class="empty">Add products to simulate.</div>';const count=16,lo=values[0],hi=values.at(-1),range=Math.max(1,hi-lo),bins=new Array(count).fill(0);values.forEach(v=>bins[clamp(Math.floor((v-lo)/range*count),0,count-1)]++);const max=Math.max(...bins,1),width=670,height=236,bw=34,gap=5,origin=35;return `<svg class="plot" viewBox="0 0 ${width} ${height}" role="img" aria-label="Distribution of simulated gross profit across 600 runs">${bins.map((n,i)=>{const hh=n/max*163,x=origin+i*(bw+gap);return `<rect x="${x}" y="${height-37-hh}" width="${bw}" height="${hh}" fill="${i>=4&&i<=11?'var(--blue)':'var(--mint)'}" rx="4"/>`;}).join('')}<line x1="30" x2="${width-10}" y1="${height-37}" y2="${height-37}" stroke="var(--border)"/><text x="30" y="${height-13}">${usd(lo)}</text><text x="${width-10}" y="${height-13}" text-anchor="end">${usd(hi)}</text></svg>`;}
function renderScenario(){for(const k of ['demand','cost','channel']){const id={demand:'demandInput',cost:'costInput',channel:'marketingInput'}[k],label={demand:'demandLabel',cost:'costLabel',channel:'marketingLabel'}[k];$(id).value=state.scenario[k];$(label).textContent=pct(state.scenario[k]);}$('presetCards').innerHTML=Object.entries(PRESETS).map(([id,x])=>`<button class="scenario ${state.scenario.preset===id?'active':''}" data-preset="${id}"><span class="ico">${x.emoji}</span><strong>${esc(x.name)}</strong><small>${esc(x.desc)}</small></button>`).join('');const rows=results(),t=totals(rows),lift=t.optProfit-t.profit;$('scenarioMetrics').innerHTML=`<div class="micro"><small>Current gross profit</small><strong>${usd(t.profit)}</strong><em>Under scenario</em></div><div class="micro"><small>Optimized gross profit</small><strong>${usd(t.optProfit)}</strong><em>Under constraints</em></div><div class="micro"><small>Modeled uplift</small><strong>${usd(lift)}</strong><em>${fmt(t.profit?100*lift/Math.abs(t.profit):0,1)}% relative lift</em></div>`;$('scenarioInsight').innerHTML=`<strong>${rows.length} products evaluated</strong><br>${t.excluded?`${t.excluded} product(s) have no feasible price under current guardrails and retain their current price.`:'All products have a feasible recommended price.'} The same scenario factors apply across active products.`;const sim=simulate(rows);$('histogram').innerHTML=histogram(sim);const percent=(p)=>sim.length?sim[Math.round((sim.length-1)*p)]:0;$('percentiles').innerHTML=`<div class="barline"><div class="barline-head"><strong>10th percentile</strong><span>${usd(percent(.1))}</span></div><div class="bar-bg"><div class="bar-fill" style="width:${sim.length?100*percent(.1)/Math.max(1,Math.abs(sim.at(-1))):0}%"></div></div></div><div class="barline"><div class="barline-head"><strong>Median</strong><span>${usd(percent(.5))}</span></div><div class="bar-bg"><div class="bar-fill" style="width:${sim.length?100*percent(.5)/Math.max(1,Math.abs(sim.at(-1))):0}%"></div></div></div><div class="barline"><div class="barline-head"><strong>90th percentile</strong><span>${usd(percent(.9))}</span></div><div class="bar-bg"><div class="bar-fill" style="width:${sim.length?100*percent(.9)/Math.max(1,Math.abs(sim.at(-1))):0}%"></div></div></div>`;}
function render(){document.body.classList.toggle('dark',state.theme==='dark');const tab=({overview:renderOverview,lab:renderLab,portfolio:renderPortfolio,scenarios:renderScenario,method:()=>{}})[currentView];tab();}
function toast(message){const e=$('toast');e.textContent=message;e.classList.add('on');clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove('on'),3100);}
function modal(mode,id){editing=mode==='edit'?id:null;let p=state.products.find(x=>x.id===id);$('modalTitle').textContent=editing?'Edit product':'Add product';let f=$('productForm');f.reset();for(const name of ['name','segment','price','cost','units','elasticity']){f.elements[name].value=p?p[name]:({name:'',segment:'Digital',price:79,cost:18,units:300,elasticity:1.6})[name];}$('modalCover').classList.add('open');$('productName').focus();}
function closeModal(){$('modalCover').classList.remove('open');editing=null;}
function download(filename,content,type){const b=new Blob([content],{type}),u=URL.createObjectURL(b),a=document.createElement('a');a.href=u;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1200);}
const csvCell=v=>{let s=String(v);if(/^[\s]*[=+@\-]/.test(s)&&!/^-?\d+(?:\.\d+)?$/.test(s.trim()))s="'"+s;return '"'+s.replaceAll('"','""')+'"';};
function exportCSV(){const rows=results(),header=['id','product','segment','current_price_usd','recommended_price_usd','baseline_units','elasticity','variable_cost_usd','current_revenue_usd','current_profit_usd','modeled_recommended_profit_usd','modeled_profit_lift_usd','recommendation_feasible'];const data=rows.map(r=>[r.p.id,r.p.name,r.p.segment,r.p.price,r.opt?r.opt.price.toFixed(2):'',r.p.units,r.p.elasticity,r.p.cost,r.cur.revenue.toFixed(2),r.cur.profit.toFixed(2),r.chosen.profit.toFixed(2),r.delta.toFixed(2),!!r.opt]);const lines=[header,...data].map(row=>row.map(csvCell).join(',')).join('\r\n');download('elasticitylab_pricing_analysis.csv','\uFEFF'+lines,'text/csv;charset=utf-8');toast('Pricing CSV downloaded.');}
function actions(action,id){const p=state.products.find(x=>x.id===id);switch(action){case 'theme':state.theme=state.theme==='dark'?'light':'dark';persist();render();break;case 'export-json':download('elasticitylab_workspace.json',JSON.stringify(state,null,2),'application/json');toast('Workspace backup downloaded.');break;case 'export-csv':exportCSV();break;case 'import-json':$('fileImport').value='';$('fileImport').click();break;case 'new-product':modal('new');break;case 'edit':if(p)modal('edit',id);break;case 'archive':if(p){p.archived=true;persist();render();toast('Product archived.');}break;case 'restore':if(p){p.archived=false;persist();render();toast('Product restored.');}break;case 'inspect':if(p){state.selected=id;persist();showView('lab');}break;case 'close-modal':closeModal();break;case 'rerun':state.seed=state.seed+1>2147483646?1:state.seed+1;persist();render();toast('Simulation rerun with a new seed.');break;case 'reset':if(confirm('Restore the six fictional demo products and reset all local settings?')){state=fresh();persist();render();toast('Demo workspace restored.');}break;}}
document.addEventListener('click',e=>{const view=e.target.closest('[data-view]');if(view){showView(view.dataset.view);return;}const preset=e.target.closest('[data-preset]');if(preset){const key=preset.dataset.preset;state.scenario={...PRESETS[key],preset:key};persist();render();toast('Scenario updated.');return;}const act=e.target.closest('[data-action]');if(act){actions(act.dataset.action,act.dataset.id||'');}});
$('labProduct').addEventListener('change',e=>{state.selected=e.target.value;persist();render();});
document.querySelectorAll('[data-setting]').forEach(input=>{input.addEventListener('input',e=>{$(e.target.id==='maxChange'?'maxChangeValue':'marginFloorValue').textContent=e.target.value+'%';});input.addEventListener('change',e=>{state.settings[e.target.dataset.setting]=Number(e.target.value);persist();render();});});
document.querySelectorAll('[data-scenario]').forEach(input=>{input.addEventListener('input',e=>{const map={demand:'demandLabel',cost:'costLabel',channel:'marketingLabel'};$(map[e.target.dataset.scenario]).textContent=pct(+e.target.value);});input.addEventListener('change',e=>{state.scenario[e.target.dataset.scenario]=Number(e.target.value);state.scenario.preset='custom';persist();render();});});
$('productForm').addEventListener('submit',e=>{e.preventDefault();const f=e.target;const vals={name:f.elements.name.value.trim(),segment:f.elements.segment.value,price:+f.elements.price.value,cost:+f.elements.cost.value,units:+f.elements.units.value,elasticity:+f.elements.elasticity.value};const entry={id:editing||'p'+Date.now().toString(36)+'x'+Math.floor(Math.random()*1e6).toString(36),...vals,archived:false};if(!validProduct(entry)){toast('Check each field and its allowed range.');return;}if(!editing&&state.products.length>=100){toast('Maximum 100 records. Archive existing entries or restore a backup.');return;}if(editing){const i=state.products.findIndex(x=>x.id===editing);if(i===-1)return;entry.archived=state.products[i].archived;state.products[i]=entry;}else{state.products.push(entry);}if(!state.selected)state.selected=entry.id;const wasEditing=!!editing;persist();closeModal();render();toast(wasEditing?'Product updated.':'Product added.');});
$('fileImport').addEventListener('change',async e=>{const file=e.target.files?.[0];if(!file)return;if(file.size>1024*1024){toast('Backup exceeds 1 MB.');return;}try{const txt=await file.text(),candidate=checked(JSON.parse(txt));if(!confirm('Replace this browser workspace with the imported backup?'))return;state=candidate;persist();render();toast('Workspace restored successfully.');}catch(err){toast('Import rejected: '+String(err.message).slice(0,95));}});
$('modalCover').addEventListener('click',e=>{if(e.target===$('modalCover'))closeModal();});document.addEventListener('keydown',e=>{if(e.key==='Escape'&&$('modalCover').classList.contains('open'))closeModal();});
window.ElasticityLabTest={calc,optimize,results,totals,simulate,checked,validProduct,getState:()=>JSON.parse(JSON.stringify(state)),fresh,persist,render,showView};
render();
})();
</script></body></html>