forked from mmocniak/r2-configurator
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
1894 lines (1861 loc) · 128 KB
/
Copy pathapp.js
File metadata and controls
1894 lines (1861 loc) · 128 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* ---------------- DATA ---------------- */
const IMG='https://media.rivian.com/image/upload/';
/* CUR_VEHICLE is the active VEHICLES[...] object; imgProgram() feeds the Rivian
visualizer CDN path so each vehicle hotlinks renders from its own program segment. */
let CUR_VEHICLE=null;
function imgProgram(){return (CUR_VEHICLE&&CUR_VEHICLE.img&&CUR_VEHICLE.img.program)||'gold-iris';}
function chipURL(code){return IMG+'dpr_auto/f_auto/w_72,q_auto:good,f_auto,c_lfill/v4/'+imgProgram()+'/visualizer/color-chips/'+code;}
/* wheel selector swatch (per-vehicle WHEEL_SWATCH map) — no parametric wheel-chip
path exists, so we hotlink the swatch Rivian serves per wheel code */
function wheelURL(code){return IMG+'dpr_auto/f_auto/w_120,q_auto:good,c_lfill/'+WHEEL_SWATCH[code];}
function interiorURL(code){return IMG+'dpr_auto/f_auto/w_72,q_auto:good,f_auto,c_lfill/v4/'+imgProgram()+'/trims/interior-finishes-chips/'+code;}
/* Hero renders — two Rivian CDN schemes, chosen by the vehicle's img config:
- default (R2): the 360 visualizer — v4/{program}/visualizer/360/{trim folder}/{wheel}/{color}
- img.compositor (R1T/R1S): Rivian's layer compositor — option codes (motor + wheel + paint
+ sprite version + the vehicle's static img.extra layers) sorted, comma-joined and
lowercased, exactly as rivian.com builds them; for these vehicles the trim's `folder`
carries its MOT-* code, and img.extra carries e.g. 'gen-2' (which selects the Gen-2
sprite layers — without it 2026-era paints/wheels fall back to defaults). Codes the
compositor doesn't know are silently ignored (it renders the default layer instead of 404ing).
Optional `vid` renders another vehicle's saved build (e.g. a loaded scenario snapshot). */
function heroURL(trim,wheel,color,vid){
const V=(vid&&VEHICLES[vid])||CUR_VEHICLE,ic=(V&&V.img)||{};
if(ic.compositor){
const codes=[ic.ver||'2023.1'].concat(ic.extra||[],[trim,wheel,color].filter(Boolean)).sort().join(',').toLowerCase();
return 'https://media.rivian.com/rivian-main/c_fill,w_1600/q_auto,f_auto/compositor/'+ic.compositor+'/'+(ic.view||'side')+'/'+codes;
}
return IMG+'dpr_auto/f_auto/q_auto:good,f_auto,c_lfill/v4/'+(ic.program||'gold-iris')+'/visualizer/360/'+trim+'/'+wheel+'/'+color+'/00001.png';
}
/* interior cabin photo (per-vehicle CABINS map) — no parametric interior visualizer
exists, so we hotlink the studio shot Rivian serves per interior code */
function cabinURL(code){return IMG+'dpr_auto/f_auto/q_auto:good,c_limit,w_1040/'+CABINS[code];}
const FEES={destination:1495,doc:377};/* national fees only; tax/title/reg/evFee are per-state (see LOC) */
/* ---------------- VEHICLE LAYER ----------------
Vehicle spec/pricing lives in data/vehicle-<id>.js (the VEHICLES map). These working
globals are re-pointed at the active vehicle by selectVehicle(); everything below reads
them exactly as before, so the R2 render path is unchanged when only R2 is loaded. */
let TRIMS,COLORS,ADDONS,CONNECT_PLUS,INTERIORS,CABINS,WHEEL_SWATCH,CMP_ACCESSORIES,GEAR_IMG,ACC_FOOTNOTE;
let TRIM_KEYS=[],CMP_ADDONS=[],INT_HEX={};
/* ≥2 loaded vehicles is what renders the header toggle at all. */
function liveVehicleIds(){return Object.keys(VEHICLES);}
/* ---------------- STATE ---------------- */
/* Per-trim Build memory: BUILD[vehicle][trim] keeps each trim's own color/wheel/interior/
drive/add-ons/Connect+ so switching trims (or vehicles) never leaks a selection between
them. Seeded from each trim's defaults — the first option in each array. */
function buildSlot(k){const t=TRIMS[k];return{
drive:t.drives?t.drives[0].id:null, /* trims with selectable drivetrains only */
color:t.colors[0],
wheel:t.wheels[0].id,
interior:t.interior[0].id,
addons:new Set(),
connectPlus:'none'
};}
const BUILD={};
const S={vehicle:'r2',trim:undefined,heroView:'ext',state2:'NC',
cmpColor:{},cmpInterior:{},cmpWheel:{},cmpDrive:{},cmpAddons:{},cmpConnectPlus:{},
accBundle:new Set(),
launchOff:false}; /* true = what-if: price the flagship Launch Edition promo out */
/* Route S.wheel / S.color / … to the active vehicle+trim's slot, so every existing
read+write below stays valid with zero call-site changes. addons is a Set mutated in
place (.add/.delete/.clear) and never reassigned, so it needs no setter. */
['drive','color','wheel','interior','connectPlus'].forEach(f=>Object.defineProperty(S,f,{
enumerable:true,get(){return BUILD[S.vehicle][S.trim][f];},set(v){BUILD[S.vehicle][S.trim][f]=v;}}));
Object.defineProperty(S,'addons',{enumerable:true,get(){return BUILD[S.vehicle][S.trim].addons;}});
/* re-seed the compare-tab's per-column selections from the active vehicle's trim defaults */
function seedCmp(){
S.cmpColor={};S.cmpInterior={};S.cmpWheel={};S.cmpDrive={};S.cmpAddons={};S.cmpConnectPlus={};
TRIM_KEYS.forEach(k=>{const t=TRIMS[k];
S.cmpColor[k]=t.colors[0];
S.cmpInterior[k]=t.interior[0].id;
S.cmpWheel[k]=t.wheels[0].id;
if(t.drives)S.cmpDrive[k]=t.drives[0].id;
S.cmpAddons[k]=new Set();
S.cmpConnectPlus[k]='none';
});
}
/* point the working globals at a vehicle and (re)seed its build + compare state */
function selectVehicle(id){
if(!VEHICLES[id])return;
CUR_VEHICLE=VEHICLES[id];
TRIMS=CUR_VEHICLE.trims;COLORS=CUR_VEHICLE.colors;ADDONS=CUR_VEHICLE.addons;
CONNECT_PLUS=CUR_VEHICLE.connectPlus;INTERIORS=CUR_VEHICLE.interiors;CABINS=CUR_VEHICLE.cabins;
WHEEL_SWATCH=CUR_VEHICLE.wheelSwatch;CMP_ACCESSORIES=CUR_VEHICLE.accessories;
GEAR_IMG=CUR_VEHICLE.gearImg;ACC_FOOTNOTE=CUR_VEHICLE.accFootnote;
TRIM_KEYS=Object.keys(TRIMS);
/* add-ons surfaced as selectable rows in the compare matrix (Launch-included or cmp-flagged) */
CMP_ADDONS=ADDONS.filter(a=>a.launchInc||a.cmp);
INT_HEX={};TRIM_KEYS.forEach(k=>TRIMS[k].interior.forEach(i=>{INT_HEX[i.id]=i.hex||'#2c2c2e';}));
S.vehicle=id;
if(!TRIMS[S.trim])S.trim=CUR_VEHICLE.flagshipTrim||TRIM_KEYS[0];
if(!BUILD[id]){BUILD[id]={};TRIM_KEYS.forEach(k=>BUILD[id][k]=buildSlot(k));}
seedCmp();
}
selectVehicle('r2');
/* Performance folds the Launch pair in free; S.launchOff simulates the promo ending */
const isLaunchInc=(t,a)=>!!(t.autoIncl&&a.launchInc&&!S.launchOff);
/* accessories sourced from the trim-comparison sheet (Gear Shop / configurator, June 2026) */
/* --- accessory catalog lives per-vehicle in data/vehicle-<id>.js (CMP_ACCESSORIES) --- */
/* ---------------- HELPERS ---------------- */
const $=id=>document.getElementById(id);
const money=n=>'$'+Math.round(n).toLocaleString('en-US');
const moneyCents=n=>{
const v=Math.round((+n||0)*100)/100;
return '$'+v.toLocaleString('en-US',{minimumFractionDigits:Number.isInteger(v)?0:2,maximumFractionDigits:2});
};
function connectPlan(plan){return (CONNECT_PLUS.plans&&CONNECT_PLUS.plans[plan])||null;}
function connectLabel(plan){const p=connectPlan(plan);return p?`${moneyCents(p.price)}/${p.period}`:'Off';}
function connectPlanName(plan){const p=connectPlan(plan);return p?`${CONNECT_PLUS.name} · ${p.name}`:'No Connect+';}
function connectAnnualCost(plan){const p=connectPlan(plan);return p?(p.period==='mo'?p.price*12:p.price):0;}
function connectTotalCost(plan,years){return connectAnnualCost(plan)*years;}
function connectSummary(plan){const p=connectPlan(plan);return p?`${CONNECT_PLUS.name} · ${p.name} (${moneyCents(p.price)}/${p.period})`:'';}
function normalizeConnect(plan){return connectPlan(plan)?plan:'none';}
/* theme-aware chart palette — dark values kick in with the OS scheme. Chart DATA
colors (baked into SVG/inline styles) read these instead of hardcoded hex; the
chart CHROME — axes/labels/legend — already flips via CSS vars. Evaluated per
render so an OS theme flip is reflected on the next calc2(). */
const DARKQ=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)');
const CHART_LIGHT={yellow:'#f4cf17',gray:'#7b8794',blue:'#4f8fd0',red:'#d6453f',
teal:'#1f7f8c',orange:'#d6783f',green:'#1f9d57',olive:'#b5790a',purple:'#9166cc',
resale:'#cdd5dd',navy:'#1d2733',statetax:'#c9547d',dest:'#e0b33c',doc:'#c2b596',
tealFill:'rgba(31,127,140,.12)',tealFill2:'rgba(31,127,140,.16)',
redFill:'rgba(214,69,63,.14)',redFillLt:'rgba(214,69,63,.07)',
greenFill:'rgba(31,157,87,.10)',redGlow:'rgba(214,69,63,.5)'};
const CHART_DARK={yellow:'#f4cf17',gray:'#8f9caa',blue:'#5fa0e0',red:'#e8635d',
teal:'#3fb2c0',orange:'#e0895a',green:'#3cc274',olive:'#d9a63a',purple:'#a986d8',
resale:'#5a6673',navy:'#e6ecf2',statetax:'#e07aa0',dest:'#e6bf5a',doc:'#c9bda0',
tealFill:'rgba(63,178,192,.16)',tealFill2:'rgba(63,178,192,.20)',
redFill:'rgba(232,99,93,.18)',redFillLt:'rgba(232,99,93,.10)',
greenFill:'rgba(60,194,116,.14)',redGlow:'rgba(232,99,93,.5)'};
function CC(){return (DARKQ&&DARKQ.matches)?CHART_DARK:CHART_LIGHT;}
/* inline Lucide + Lucide Lab icons (offline-safe) */
const ICONS={
zap:'<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
palette:'<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
wheel:'<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.4"/>',
seat:'<path d="M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3"/><path d="M3 11v5a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v2H7v-2a2 2 0 0 0-4 0Z"/><path d="M5 18v2"/><path d="M19 18v2"/>',
gearboxSquare:'<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 7v10"/><path d="M12 7v10"/><path d="M17 7v5H7"/>',
steeringWheel:'<circle cx="12" cy="12" r="10"/><path d="m3.3 7 7 4"/><path d="m13.7 11 7-4"/><path d="M12 14v8"/><circle cx="12" cy="12" r="2"/>',
caravan:'<path d="M18 19V9a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v8a2 2 0 0 0 2 2h2"/><path d="M2 9h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H2"/><path d="M22 17v1a1 1 0 0 1-1 1H10v-9a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v9"/><circle cx="8" cy="19" r="2"/>',
charge:'<path d="M15 7h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-2"/><path d="M6 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h1"/><path d="m11 7-3 5h4l-3 5"/><line x1="22" x2="22" y1="11" y2="13"/>',
check:'<path d="M20 6 9 17l-5-5"/>',
plug:'<path d="M9 2v5"/><path d="M15 2v5"/><path d="M6 7h12v4a6 6 0 0 1-12 0Z"/><path d="M12 17v5"/>',
wifi:'<path d="M5 13a10 10 0 0 1 14 0"/><path d="M8.5 16.5a5 5 0 0 1 7 0"/><path d="M12 20h.01"/>',
rack:'<path d="M3 7h18"/><path d="M3 17h18"/><path d="M6 7v10"/><path d="M12 7v10"/><path d="M18 7v10"/>',
mats:'<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 3v18"/><path d="M4 9h4"/><path d="M4 15h4"/>',
box:'<path d="M21 8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
sun:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.9 4.9 1.4 1.4"/><path d="m17.7 17.7 1.4 1.4"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.3 17.7-1.4 1.4"/><path d="m19.1 4.9-1.4 1.4"/>',
monitor:'<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/>',
tablet:'<rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 18h.01"/>',
utensils:'<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/>',
bike:'<circle cx="18.5" cy="17.5" r="3.5"/><circle cx="5.5" cy="17.5" r="3.5"/><circle cx="15" cy="5" r="1"/><path d="M12 17.5V14l-3-3 4-3 2 3h2"/>'
};
function ico(name,size){size=size||18;return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]||''}</svg>`;}
function curTrim(){return TRIMS[S.trim];}
function curWheel(){return curTrim().wheels.find(w=>w.id===S.wheel)||curTrim().wheels[0];}
/* selected drive system (Standard has 3; other trims have a single fixed drivetrain) */
function curDrive(){const t=curTrim();if(!t.drives)return null;return t.drives.find(d=>d.id===S.drive)||t.drives[0];}
function curRange(){const d=curDrive();return (d?d.range:curTrim().range)+curWheel().rd;}
function curHP(){const d=curDrive();return d?d.hp:curTrim().hp;}
function curZ60(){const d=curDrive();return d?d.z60:curTrim().z60;}
function curDriveLabel(){const d=curDrive();return d?d.drive:curTrim().drive;}
function curMotors(){const d=curDrive();return d?d.motors:curTrim().motors;}
function curTow(){const d=curDrive();return d?d.tow:curTrim().tow;}
function curAvail(){const d=curDrive();return d?d.avail:curTrim().avail;}
/* An option ships today when avail reads "Available now" (or is unset); any
other value — a future quarter/year or "Coming soon" — isn't orderable yet. */
function isSoon(avail){var t=(avail||'').trim();return !!t&&!/available now/i.test(t);}
function soonPill(avail){return isSoon(avail)?`<span class="soon">${avail}</span>`:'';}
/* ---------------- BUILD: trims ---------------- */
function renderTrims(){
const row=$('trimRow');row.innerHTML='';
Object.entries(TRIMS).forEach(([k,t])=>{
const d=document.createElement('div');
d.className='trim'+(S.trim===k?' sel':'');
d.innerHTML=`<div class="check">${ico('check',13)}</div><div class="tn">${t.short}</div>
<div class="tp">${money(t.price)}</div>
<div class="ts">${t.motors} · ${t.drive} · ${t.hp} hp<br>${t.range} mi · 0–60 ${t.z60}</div>
<span class="av${isSoon(t.avail)?' soon':''}">${t.avail}</span>`;
d.onclick=()=>{S.trim=k;S.heroView='ext';renderAll();};
row.appendChild(d);
});
}
/* ---------------- BUILD: hero render ---------------- */
function renderHero(){
const t=curTrim();const col=COLORS[S.color];const img=$('heroImg');
img.style.display='';$('heroPh').style.display='none';
img.onerror=()=>{img.style.display='none';$('heroPh').style.display='';};
if(S.heroView==='int'){
const io=t.interior.find(i=>i.id===S.interior)||t.interior[0];
img.src=cabinURL(io.code);
$('heroCap').textContent=`${t.short} · ${io.name} interior`;
}else{
img.src=heroURL(t.folder,curWheel().code,col.code);
$('heroCap').textContent=`${t.short} · ${col.name} · ${curWheel().name}`;
}
/* reflect active view on the toggle buttons */
document.querySelectorAll('#heroView button').forEach(b=>b.classList.toggle('on',b.dataset.view===S.heroView));
}
/* ---------------- BUILD: branches ---------------- */
function makeNode(o){
const el=document.createElement('div');
el.className='node'+(o.sel?' sel':'')+(o.locked?' locked':'');
let chip='';
if(o.chip==='color')chip=`<span class="chip" style="background:${o.hex}"><img src="${chipURL(o.code)}" loading="lazy" onerror="this.style.display='none'"></span>`;
if(o.chip==='wheel')chip=`<span class="chip wheel"><img src="${wheelURL(o.code)}" loading="lazy" onerror="this.style.display='none'"></span>`;
if(o.chip==='interior')chip=`<span class="chip" style="background:${o.hex}"><img src="${interiorURL(o.code)}" loading="lazy" onerror="this.style.display='none'"></span>`;
const price=o.price===null?'':(o.price>0
?`+<b>${o.period?moneyCents(o.price):money(o.price)}</b>${o.period?'/'+o.period:''}`
:`<b>Included</b>`);
el.innerHTML=`<div class="check">${ico('check',12)}</div><div class="nm">${chip}${o.label}</div>
<div class="pr">${price}</div>${o.spec?`<div class="pr" style="margin-top:3px;color:var(--faint)">${o.spec}</div>`:''}${o.tag?`<span class="tag">${o.tag}</span>`:''}${soonPill(o.avail)}`;
if(o.onclick&&!o.locked)el.onclick=o.onclick;
return el;
}
/* dedicated drive-system card: range is the hero, paired with price; specs demoted */
function makeDriveNode(o){
const el=document.createElement('div');
el.className='dnode'+(o.sel?' sel':'')+(o.onclick?'':' fixed');
const price=o.price>0?`+${money(o.price)}`:`<span class="dincl">Included</span>`;
const note=(o.note&&o.note!=='Included')?` · ${o.note}`:'';
el.innerHTML=`<div class="check">${ico('check',12)}</div>
<div class="dname">${o.name}</div>
<div class="dvar">${o.sub}</div>
<div class="dkey">
<div class="drange"><b>${o.range}</b> mi <i>est.</i></div>
<div class="dprice">${price}</div>
</div>
<div class="dspecs">${o.hp} hp · 0–60 ${o.z60} · ${o.tow} tow</div>
<div class="davail">${soonPill(o.avail)||o.avail}${note}</div>`;
if(o.onclick)el.onclick=o.onclick;
return el;
}
function branch(ic,title,meta,nodes){
const b=document.createElement('div');
b.innerHTML=`<div class="branchhead"><span class="ic">${ic}</span>${title}<span class="meta">${meta||''}</span></div>`;
const n=document.createElement('div');n.className='nodes';
nodes.forEach(o=>n.appendChild(makeNode(o)));
b.appendChild(n);return b;
}
function renderBranches(){
const t=curTrim();const host=$('treeBranches');host.innerHTML='';
/* Performance-only: Launch Edition promo switch — full-width banner above the config tree */
const lb=$('launchBanner');
if(lb){
if(t.autoIncl){
lb.innerHTML=`<div class="launchbanner${S.launchOff?' off':''}">
<span class="lbic">${ico('zap',15)}</span>
<span class="lbtext"><b>Launch Edition promotion</b>
<span class="lbsub">${S.launchOff
?'Off — pricing shown as if the promotion has ended: Autonomy+ and the Tow Package price individually below.'
:'Autonomy+, Tow Package & Launch key fob included — limited time.'}</span></span>
<button type="button" class="swtoggle" role="switch" aria-checked="${!S.launchOff}" aria-label="Launch Edition promotion"><span class="knob"></span></button></div>`;
lb.querySelector('.swtoggle').onclick=()=>{S.launchOff=!S.launchOff;renderAll();};
}else lb.innerHTML='';
}
/* one "Drive system" card grid for every trim: Standard's are selectable, fixed trims render one Included card */
const drives=t.drives||[{
name:t.drive==='AWD'?'All-Wheel Drive':'Rear-Wheel Drive',
sub:`${t.motors} · Large pack (~87.9 kWh)`,
price:0,range:t.range,hp:t.hp,z60:t.z60,tow:t.tow,avail:t.avail,note:'Included',sel:true
}];
const wrap=document.createElement('div');
wrap.innerHTML=`<div class="branchhead"><span class="ic">${ico('gearboxSquare')}</span>Drive system<span class="meta">${curRange()} mi configured</span></div>`;
const grid=document.createElement('div');grid.className='nodes drivenodes';
drives.forEach(o=>{
const opts=Object.assign({},o);
if(t.drives){opts.sel=S.drive===o.id;opts.onclick=()=>{S.drive=o.id;renderAll();};}
grid.appendChild(makeDriveNode(opts));
});
wrap.appendChild(grid);host.appendChild(wrap);
host.appendChild(branch(ico('palette'),'Paint',COLORS[S.color].name,t.colors.map(id=>{
const c=COLORS[id];
return {label:c.name,price:c.price,sel:S.color===id,chip:'color',code:c.code,hex:c.hex,tag:c.note||'',avail:c.avail,
onclick:()=>{S.color=id;S.heroView='ext';renderAll();}};
})));
host.appendChild(branch(ico('wheel'),'Wheels & tires','',t.wheels.map(w=>({
label:w.name,price:w.price,sel:S.wheel===w.id,chip:'wheel',code:w.code,
tag:(w.rd?`${w.rd} mi range`:'')+(w.note?(w.rd?' · ':'')+w.note:''),
onclick:()=>{S.wheel=w.id;S.heroView='ext';renderAll();}}))));
host.appendChild(branch(ico('seat'),'Interior','',t.interior.map(i=>({
label:i.name,price:i.price,sel:S.interior===i.id,chip:'interior',code:i.code,hex:intHex(i.id),tag:i.note||'',avail:i.avail,
onclick:()=>{S.interior=i.id;S.heroView='int';renderAll();}}))));
const groups={};ADDONS.forEach(a=>{(groups[a.grp]=groups[a.grp]||[]).push(a);});
const grpIcon={'Driver assistance':'steeringWheel','Towing & utility':'caravan'};
Object.entries(groups).forEach(([g,items])=>{
host.appendChild(branch(ico(grpIcon[g]||'zap'),g,'',items.map(a=>{
/* locked-included two ways: the launch promo, or a trim that bundles it (a.inclTrims) */
const launch=isLaunchInc(t,a),inc=launch||(a.inclTrims||[]).includes(S.trim);
return {label:a.name,price:inc?0:a.price,sel:inc||S.addons.has(a.id),locked:inc,
tag:launch?'Included (Launch)':(inc?'Included':''),
onclick:inc?null:()=>{S.addons.has(a.id)?S.addons.delete(a.id):S.addons.add(a.id);renderAll();}};
})));
});
const yp=connectPlan('yearly'), mp=connectPlan('monthly');
const connectOpts=[
{id:'none',label:'No Connect+',price:null},
{id:'yearly',label:'Connect+ yearly',price:yp.price,period:yp.period},
{id:'monthly',label:'Connect+ monthly',price:mp.price,period:mp.period}
];
host.appendChild(branch(ico('wifi'),'Connected services','',connectOpts.map(o=>({
label:o.label,price:o.price,period:o.period,sel:S.connectPlus===o.id,
onclick:()=>{S.connectPlus=o.id;renderAll();}
}))));
const lk=document.createElement('div');lk.className='note';
lk.innerHTML=`Accessories & gear: <a href="https://rivian.com/gear-shop" target="_blank" rel="noopener">Rivian Gear Shop ↗</a> · Driver assist: <a href="https://rivian.com/autonomy" target="_blank" rel="noopener">Autonomy+ ↗</a> · Connected services: <a href="${CONNECT_PLUS.link}" target="_blank" rel="noopener">Connect+ ↗</a>`;
host.appendChild(lk);
}
/* ---------------- BUILD: price + summary ---------------- */
function configuredPrice(){
const t=curTrim();let p=t.price;
const d=curDrive();if(d)p+=d.price;
p+=COLORS[S.color].price;
p+=curWheel().price;
p+=(t.interior.find(i=>i.id===S.interior)||{price:0}).price;
ADDONS.forEach(a=>{const inc=isLaunchInc(t,a);if(!inc&&S.addons.has(a.id))p+=a.price;});
return p;
}
function renderSummary(){
const t=curTrim();const price=configuredPrice();
$('cfgPrice').textContent=Math.round(price).toLocaleString('en-US');
$('specChips').innerHTML=`<div class="c">Range<b>${curRange()} mi</b></div><div class="c">Power<b>${curHP()} hp</b></div><div class="c">Drive<b>${curDriveLabel()}</b></div><div class="c">Max tow<b>${curTow()}</b></div>`;
const lines=[`<div class="sumline"><span>${t.name} base</span><span>${money(t.price)}</span></div>`];
const add=(l,v)=>lines.push(`<div class="sumline"><span>${l}</span><span>+${money(v)}</span></div>`);
const d=curDrive();if(d&&d.price)add('Drive · '+d.name+(d.sub?' '+d.sub:''),d.price);
const c=COLORS[S.color];if(c.price)add('Paint · '+c.name,c.price);
const w=curWheel();if(w.price)add('Wheels · '+w.name,w.price);
const it=t.interior.find(i=>i.id===S.interior);if(it&&it.price)add('Interior · '+it.name,it.price);
ADDONS.forEach(a=>{const inc=isLaunchInc(t,a);if(!inc&&S.addons.has(a.id))add(a.name,a.price);});
lines.push(`<div class="sumline tot"><span>Configured price</span><span>${money(price)}</span></div>`);
const gear=accBundleTotal();if(gear)lines.push(`<div class="sumline"><span>Gear & accessories</span><span>+${money(gear)}</span></div>`);
if(connectPlan(S.connectPlus))lines.push(`<div class="sumline"><span>${connectPlanName(S.connectPlus)}</span><span>${connectLabel(S.connectPlus)}</span></div>`);
$('sumLines').innerHTML=lines.join('');
}
/* ---------------- COMPARE ---------------- */
/* per-column selections: each trim carries its own paint + interior; Standard also its own drive system */
function cmpDriveObj(k){const t=TRIMS[k];if(!t.drives)return null;return t.drives.find(d=>d.id===S.cmpDrive[k])||t.drives[0];}
function cmpBaseDriveObj(k){const t=TRIMS[k];return t.drives?t.drives[0]:null;}
function cmpColorId(k){const c=S.cmpColor[k];return TRIMS[k].colors.includes(c)?c:TRIMS[k].colors[0];}
function cmpIntObj(k){const t=TRIMS[k];return t.interior.find(i=>i.id===S.cmpInterior[k])||t.interior[0];}
function cmpWheelObj(k){const t=TRIMS[k];return t.wheels.find(w=>w.id===S.cmpWheel[k])||t.wheels[0];}
function intHex(id){return INT_HEX[id]||'#2c2c2e';}
/* the halo/flagship column (defaults to the last trim) and its per-cell class */
function flagshipKey(){return (CUR_VEHICLE.flagshipTrim&&TRIMS[CUR_VEHICLE.flagshipTrim])?CUR_VEHICLE.flagshipTrim:TRIM_KEYS[TRIM_KEYS.length-1];}
function pcol(k){return k===flagshipKey()?'perfcol':'';}
/* the Launch-Edition promo exists only when a trim auto-includes launch-flagged add-ons */
function hasLaunchPromo(){return TRIM_KEYS.some(k=>TRIMS[k].autoIncl)&&ADDONS.some(a=>a.launchInc);}
function cmpAddonTotal(k){
const t=TRIMS[k];let sum=0;
CMP_ADDONS.forEach(a=>{const inc=isLaunchInc(t,a);if(!inc&&S.cmpAddons[k].has(a.id))sum+=a.price;});
return sum;
}
/* one shared gear bundle — same total applied to every trim */
function accBundleTotal(){
let sum=0;
CMP_ACCESSORIES.forEach(g=>g.items.forEach(a=>{if(a.price&&S.accBundle.has(a.id))sum+=a.price;}));
return sum;
}
function trimCfg(k){
const t=TRIMS[k];
const colId=cmpColorId(k);const c=COLORS[colId];const paint=c.price;
const io=cmpIntObj(k);const interior=io.price||0;
const wo=cmpWheelObj(k);const wheel=wo.price||0;
let drive=0,driveObj=null;
if(t.drives){driveObj=cmpDriveObj(k);drive=driveObj.price;}
const addon=cmpAddonTotal(k);const acc=accBundleTotal();
const vehicle=t.price+drive+paint+interior+wheel+addon;
return {price:vehicle+acc,vehicle,colId,c,io,paint,interior,wo,wheel,drive,driveObj,addon,acc};
}
function miniChip(code,hex,interior){return `<span class="chip mini" style="background:${hex}"><img src="${(interior?interiorURL:chipURL)(code)}" loading="lazy" onerror="this.style.display='none'"></span>`;}
/* interactive per-column selector cells (swatches live in the matrix) */
function selRow(label,kind){
return `<tr class="cfgrow"><td class="lab cfg-lab">${label}</td>${TRIM_KEYS.map(k=>selCell(k,kind)).join('')}</tr>`;
}
function priceTag(p){return p>0?`<span class="opx add">+${money(p)}</span>`:`<span class="opx free">incl.</span>`;}
function selCell(k,kind){
const cls=k===flagshipKey()?'perfcol ':'';
if(kind==='connectPlus'){
const chips=['none','yearly','monthly'].map(id=>{
const sel=S.cmpConnectPlus[k]===id;
const lbl=id==='none'?'Off':connectPlan(id).name;
const price=id==='none'?'<span class="opx free">off</span>':`<span class="opx add">${connectLabel(id)}</span>`;
return `<div class="optchip${sel?' sel':''}" data-sw="connectPlus" data-k="${k}" data-id="${id}" title="${id==='none'?'No Connect+':CONNECT_PLUS.name+' '+connectPlan(id).name}"><span class="onm">${lbl}</span>${price}</div>`;
}).join('');
return `<td class="${cls}"><div class="optlist">${chips}</div></td>`;
}
if(kind==='drive'){
if(!TRIMS[k].drives)return `<td class="${cls}"><div class="optlist"><div class="optchip ro"><span class="onm">${TRIMS[k].motors} ${TRIMS[k].drive}</span><span class="onote">not configurable</span></div></div></td>`;
const chips=TRIMS[k].drives.map(d=>
`<div class="optchip${S.cmpDrive[k]===d.id?' sel':''}" data-sw="drive" data-k="${k}" data-id="${d.id}" title="${d.name} · ${d.sub}"><span class="onm">${d.drive} · ${d.sub}</span>${priceTag(d.price)}</div>`
).join('');
return `<td class="${cls}"><div class="optlist">${chips}</div></td>`;
}
if(kind==='color'){
const sel=cmpColorId(k);
const chips=TRIMS[k].colors.map(id=>{const o=COLORS[id];
return `<div class="optchip${sel===id?' sel':''}" data-sw="color" data-k="${k}" data-id="${id}" title="${o.name}"><span class="sw" style="background:${o.hex}"><img src="${chipURL(o.code)}" loading="lazy" onerror="this.style.display='none'"></span><span class="onm">${o.name}</span>${priceTag(o.price)}</div>`;
}).join('');
return `<td class="${cls}"><div class="optlist">${chips}</div></td>`;
}
if(kind==='wheel'){
const wsel=cmpWheelObj(k).id;
const chips=TRIMS[k].wheels.map(w=>
`<div class="optchip${wsel===w.id?' sel':''}" data-sw="wheel" data-k="${k}" data-id="${w.id}" title="${w.name}${w.rd?` · ${w.rd} mi range`:''}"><span class="sw wheel"><img src="${wheelURL(w.code)}" loading="lazy" onerror="this.style.display='none'"></span><span class="onm">${w.name}</span>${priceTag(w.price)}</div>`
).join('');
return `<td class="${cls}"><div class="optlist">${chips}</div></td>`;
}
if(TRIMS[k].interior.length<2){
const io=cmpIntObj(k);
return `<td class="${cls}"><div class="optlist"><div class="optchip sel ro"><span class="sw" style="background:${intHex(io.id)}"><img src="${interiorURL(io.code)}" loading="lazy" onerror="this.style.display='none'"></span><span class="onm">${io.name}</span><span class="opx free">standard</span></div></div></td>`;
}
const chips=TRIMS[k].interior.map(i=>
`<div class="optchip${S.cmpInterior[k]===i.id?' sel':''}" data-sw="interior" data-k="${k}" data-id="${i.id}" title="${i.name}"><span class="sw" style="background:${intHex(i.id)}"><img src="${interiorURL(i.code)}" loading="lazy" onerror="this.style.display='none'"></span><span class="onm">${i.name}</span>${priceTag(i.price)}</div>`
).join('');
return `<td class="${cls}"><div class="optlist">${chips}</div></td>`;
}
/* interactive per-column add-on toggle (Performance includes the Launch pair free) */
function addonRow(label,id,price){
return `<tr class="cfgrow"><td class="lab cfg-lab">${label}</td>${TRIM_KEYS.map(k=>addonCell(k,id,price)).join('')}</tr>`;
}
function addonCell(k,id,price){
const cls=k===flagshipKey()?'perfcol ':'';
const a=ADDONS.find(x=>x.id===id);
if(isLaunchInc(TRIMS[k],a))
return `<td class="${cls}"><div class="optlist"><div class="optchip sel ro"><span class="onm">Included</span><span class="onote">with Launch Edition</span></div></div></td>`;
const on=S.cmpAddons[k].has(id);
return `<td class="${cls}"><div class="optlist"><div class="optchip toggle${on?' sel':''}" data-add="${id}" data-k="${k}" title="${a.name}">${on?`<span class="ack">${ico('check',11)}</span>`:''}<span class="onm">${on?'Added':'Add'}</span>${priceTag(price)}</div></div></td>`;
}
/* Launch Edition promo toggle row — the Performance what-if, mirrored from the Build tab */
function promoRow(){
const on=!S.launchOff;
const chip=`<div class="optchip toggle${on?' sel':''}" data-promo title="Launch Edition promotion">${on?`<span class="ack">${ico('check',11)}</span>`:''}<span class="onm">${on?'Active':'Ended'}</span><span class="onote">${on?'bundles the add-ons below':'what-if · add-ons price out'}</span></div>`;
const cells=TRIM_KEYS.map(k=>TRIMS[k].autoIncl
?`<td class="${pcol(k)}"><div class="optlist">${chip}</div></td>`
:`<td class="no ${pcol(k)}">—</td>`).join('');
return `<tr class="cfgrow"><td class="lab cfg-lab">Launch Edition promo</td>${cells}</tr>`;
}
/* shared gear parts list — one card per item, photo + tooltip */
function gearCard(a){
const on=S.accBundle.has(a.id);
const tip=`<span class="tip"><b>${a.name}</b> · +${money(a.price)}<br>${a.note} <a href="${a.link}" target="_blank" rel="noopener">View ↗</a></span>`;
return `<div class="gear${on?' on':''}" data-acc="${a.id}">
<span class="check">${ico('check',12)}</span>
<span class="ic"><span class="ph">${ico(a.icon,22)}</span><img src="${a.img}" loading="lazy" alt="${a.name}" onerror="this.style.display='none'"></span>
<span class="gbody"><span class="gnm">${a.name}<span class="info" tabindex="0" role="button" aria-label="${a.name} details">i</span>${tip}${soonPill(a.avail)}</span><span class="gpx">+${money(a.price)}</span></span>
</div>`;
}
function renderGearBody(body){
if(!body)return;
body.innerHTML=CMP_ACCESSORIES.map(g=>
`<div class="grphead">${g.grp}</div><div class="gearlist">${g.items.map(gearCard).join('')}</div>`
).join('')+`<div class="accnote-gear" style="font-size:11px;color:var(--muted);line-height:1.55;margin-top:16px">${ACC_FOOTNOTE}</div>`;
body.querySelectorAll('.gear').forEach(el=>el.onclick=ev=>{
if(ev.target.closest('.info')||ev.target.closest('.tip'))return;
const id=el.dataset.acc;S.accBundle.has(id)?S.accBundle.delete(id):S.accBundle.add(id);renderAll();
});
}
function renderGear(){
renderGearBody($('buildGearBody'));
renderGearBody($('gearBody'));
const n=S.accBundle.size,total=accBundleTotal();
const label=n?`· <b>${money(total)}</b> gear selected`:'';
const buildSum=$('buildGearSum');if(buildSum)buildSum.innerHTML=label;
const sum=$('gearSum');if(sum)sum.innerHTML=label;
}
/* pinned summary: each column's live configured total, lowest flagged */
function totalRow(){
const c={};TRIM_KEYS.forEach(k=>c[k]=trimCfg(k));
const min=Math.min(...TRIM_KEYS.map(k=>c[k].price));
const anyAcc=TRIM_KEYS.some(k=>c[k].acc>0);
return `<tr class="totalrow"><td class="lab">Total</td>${TRIM_KEYS.map(k=>totalCell(k,c[k],c[k].price===min,anyAcc)).join('')}</tr>`;
}
function renderCompare(){
const host=$('cmpCards');host.innerHTML='';
TRIM_KEYS.forEach(k=>{
const cls=k===flagshipKey()?' perf':'';
const t=TRIMS[k];const cfg=trimCfg(k);
const colId=cfg.colId;const w=cfg.wo;const io=cfg.io;const hex=intHex(io.id);
const availTxt=cfg.driveObj?cfg.driveObj.avail:t.avail;
const brk=`${money(t.price)} base${cfg.drive?` + ${money(cfg.drive)} drive`:''}${cfg.paint?` + ${money(cfg.paint)} paint`:''}${cfg.wheel?` + ${money(cfg.wheel)} wheels`:''}${cfg.interior?` + ${money(cfg.interior)} interior`:''}${cfg.addon?` + ${money(cfg.addon)} add-ons`:''}${cfg.acc?` + ${money(cfg.acc)} accessories`:''}`;
const connect=connectSummary(S.cmpConnectPlus[k]);
const card=document.createElement('div');card.className='cmpcard'+cls;
card.innerHTML=`
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div><h3>${t.short}</h3><div class="av${isSoon(availTxt)?' soon':''}">${availTxt}</div></div>
<div style="text-align:right"><div class="price">${money(cfg.price)}</div><div class="cardbreak">as configured</div></div>
</div>
<div class="hero" style="margin:12px 0 0;position:relative">
<img alt="${t.short}" loading="lazy" src="${heroURL(t.folder,w.code,COLORS[colId].code)}" onerror="this.style.display='none';this.nextElementSibling.style.display='block'">
<div class="ph" style="display:none">Render needs internet</div>
<div class="cap">${t.short} · ${COLORS[colId].name} · ${w.name}</div>
</div>
<div class="cardrow"><span class="chip" style="background:${hex}"><img src="${interiorURL(io.code)}" loading="lazy" onerror="this.style.display='none'"></span><span>${io.name}${io.price?` · +${money(io.price)}`:' · included'}</span></div>
<div class="cardbreak" style="margin-top:8px">${brk}</div>
${connect?`<div class="cardbreak" style="margin-top:5px">${connect}</div>`:''}
<button class="btn cmplaunch" data-launch="${k}" style="margin-top:13px">See cost over time <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg></button>`;
host.appendChild(card);
});
host.querySelectorAll('[data-launch]').forEach(b=>b.onclick=()=>launchCost2(b.dataset.launch));
$('cmpMatrix').innerHTML=buildMatrix();
$('cmpMatrix').querySelectorAll('[data-sw]').forEach(el=>el.onclick=()=>{
const k=el.dataset.k,kind=el.dataset.sw,id=el.dataset.id;
if(kind==='color')S.cmpColor[k]=id;else if(kind==='interior')S.cmpInterior[k]=id;else if(kind==='wheel')S.cmpWheel[k]=id;else if(kind==='drive')S.cmpDrive[k]=id;else if(kind==='connectPlus')S.cmpConnectPlus[k]=normalizeConnect(id);
renderCompare();
});
$('cmpMatrix').querySelectorAll('[data-add]').forEach(el=>el.onclick=()=>{
const k=el.dataset.k,id=el.dataset.add;
S.cmpAddons[k].has(id)?S.cmpAddons[k].delete(id):S.cmpAddons[k].add(id);
renderCompare();
});
/* the promo flag is shared with the Build tab, so re-render everything */
$('cmpMatrix').querySelectorAll('[data-promo]').forEach(el=>el.onclick=()=>{S.launchOff=!S.launchOff;renderAll();});
renderGear();
updateVerdict();
updateMobileCmpHead();
}
function updateMobileCmpHead(){
const cmp=document.querySelector('.mobile-cmp'),visual=document.querySelector('.mobile-cmp-visual');
if(!cmp||!visual)return;
const r=visual.getBoundingClientRect();
cmp.classList.toggle('show-sticky',r.bottom<=0);
}
/* drive the scroll-fade mask on the cost results column: fade the top edge only when
scrolled down, the bottom edge only when more content sits below (see .sticky.cost-scroll) */
function updateColFade(col){
const FADE=32, EPS=2;
const up = col.scrollTop > EPS;
const dn = col.scrollTop + col.clientHeight < col.scrollHeight - EPS;
col.style.setProperty('--fade-top', up?FADE+'px':'0px');
col.style.setProperty('--fade-bot', dn?FADE+'px':'0px');
}
/* fixed cost-summary bar: show once the charts section reaches the viewport top on the active cost tab */
function updateCostSticky(){
const view=$('view-cost2'),bar=$('coststicky'),sec=$('costModelSection');
if(!view||!bar||!sec)return;
if(!view.classList.contains('active')){bar.classList.remove('show');bar.setAttribute('aria-hidden','true');return;}
/* show once the first input group ("The basics") scrolls out of view, and keep
it for the rest of the page (fallback: charts reached the top) */
const first=view.querySelector('.igstack .igroup');
const show=first?first.getBoundingClientRect().bottom<=0:sec.getBoundingClientRect().top<=0;
bar.classList.toggle('show',show);
bar.setAttribute('aria-hidden',show?'false':'true');
/* pin the results column below the fixed bar. The column is about as tall as
the input column, so in-flow sticky has no travel room — instead cap it to
the viewport and let its own content scroll when it doesn't fit. */
const col=view.querySelector('.grid2 .sticky');
if(col){
if(getComputedStyle(col).position==='sticky'){
const topBase=show?76:18;
col.style.top=topBase+'px';
col.style.maxHeight=(window.innerHeight-topBase-18)+'px';
col.style.overflowY='auto';
col.classList.add('cost-scroll');
if(!col.dataset.fadeBound){
col.dataset.fadeBound='1';
col.addEventListener('scroll',()=>updateColFade(col),{passive:true});
}
updateColFade(col);
}else{col.style.top='';col.style.maxHeight='';col.style.overflowY=''; /* phones: column is static */
col.classList.remove('cost-scroll');col.style.removeProperty('--fade-top');col.style.removeProperty('--fade-bot');}
}
}
function cmpCell(v,colcls){
const c=colcls?(' '+colcls):'';
if(v===true)return `<td class="yes${c}">${ico('check',15)}Included</td>`;
if(v===false)return `<td class="no${c}">—</td>`;
if(v==='launch')return `<td class="yes launch${c}">${ico('check',15)}Included<small>with Launch Edition</small></td>`;
if(v==='opt25')return `<td class="opt${c}">Optional<small>+$2,500</small></td>`;
if(v==='opt950')return `<td class="opt${c}">Optional<small>+$950</small></td>`;
if(v==='excl2000')return `<td class="excl${c}">Exclusive option<small>+$2,000 · Performance only</small></td>`;
return `<td class="val${c}">${v}</td>`;
}
function mobileCmpCell(k,td){
const m=td.match(/^<td(?: class="([^"]*)")?>/);
const cls=m&&m[1]?` ${m[1]}`:'';
const body=td.replace(/^<td(?: class="[^"]*")?>/,'').replace(/<\/td>$/,'');
return `<div class="mobile-cmp-val${cls}"><div class="mobile-cmp-trim">${TRIMS[k].short}</div><div class="mobile-cmp-body">${body}</div></div>`;
}
function mobileCmpRow(label,klass,cells){
return `<div class="mobile-cmp-row ${klass}"><div class="mobile-cmp-label">${label}</div><div class="mobile-cmp-values">${TRIM_KEYS.map((k,i)=>mobileCmpCell(k,cells[i])).join('')}</div></div>`;
}
function mobileCmpDivider(label,klass=''){
return `<div class="mobile-cmp-divider ${klass}">${label}</div>`;
}
function mobileCmpHead(totals){
const cells=TRIM_KEYS.map(k=>{
const t=TRIMS[k],cfg=totals[k];
return `<div class="mobile-cmp-headcell"><span>${t.short}</span><b>${money(cfg.vehicle)}</b></div>`;
}).join('');
const visual=TRIM_KEYS.map(k=>{
const t=TRIMS[k],cfg=totals[k];
return `<div class="mobile-cmp-visualcell"><img src="${heroURL(t.folder,cfg.wo.code,COLORS[cfg.colId].code)}" loading="lazy" alt="${t.short}" onerror="this.style.display='none'"><span>${t.short}</span><b>${money(cfg.vehicle)}</b></div>`;
}).join('');
return `<div class="mobile-cmp-visual">${visual}</div><div class="mobile-cmp-head">${cells}</div>`;
}
function mobileOptGroup(label,rows){
return `<div class="mobile-opt-group"><div class="mobile-cmp-label">${label}</div><div class="mobile-opt-list">${rows}</div></div>`;
}
function mobileOptRow(label,cells){
return `<div class="mobile-opt-row"><div class="mobile-opt-name">${label}</div><div class="mobile-opt-values">${cells.join('')}</div></div>`;
}
function mobileOptCell(k,{kind,id,label,selected=false,unavailable=false,readonly=false,add=false}){
const tag=unavailable?'—':label;
const cls=`mobile-opt-cell${selected?' sel':''}${unavailable?' na':''}${readonly?' ro':''}`;
const attrs=!unavailable&&!readonly
? add?` data-add="${id}" data-k="${k}"`:` data-sw="${kind}" data-k="${k}" data-id="${id}"`
:'';
const node=!unavailable&&!readonly?'button':'div';
return `<${node} class="${cls}"${attrs}>${selected?`${ico('check',11)} `:''}${tag}</${node}>`;
}
function mobileColorGroup(){
const ids=Object.keys(COLORS).filter(id=>TRIM_KEYS.some(k=>TRIMS[k].colors.includes(id)));
return mobileOptGroup('Paint',ids.map(id=>{
const o=COLORS[id];
const sw=`<span class="mobile-opt-swatch" style="background:${o.hex}"><img src="${chipURL(o.code)}" loading="lazy" onerror="this.style.display='none'"></span>${o.name}`;
const cells=TRIM_KEYS.map(k=>{
const supported=TRIMS[k].colors.includes(id);
return mobileOptCell(k,{kind:'color',id,label:o.price?`+${money(o.price)}`:'Included',selected:cmpColorId(k)===id,unavailable:!supported});
});
return mobileOptRow(sw,cells);
}).join(''));
}
function mobileWheelGroup(){
const seen=new Set(),opts=[];
TRIM_KEYS.forEach(k=>TRIMS[k].wheels.forEach(w=>{if(!seen.has(w.id)){seen.add(w.id);opts.push(w);}}));
return mobileOptGroup('Wheels',opts.map(w=>{
const label=`<span class="mobile-opt-swatch wheel"><img src="${wheelURL(w.code)}" loading="lazy" onerror="this.style.display='none'"></span>${w.name}`;
const cells=TRIM_KEYS.map(k=>{
const tw=TRIMS[k].wheels.find(x=>x.id===w.id);
return mobileOptCell(k,{kind:'wheel',id:w.id,label:tw?(tw.price?`+${money(tw.price)}`:'Included'):'—',selected:tw&&cmpWheelObj(k).id===w.id,unavailable:!tw});
});
return mobileOptRow(label,cells);
}).join(''));
}
function mobileInteriorGroup(){
const seen=new Set(),opts=[];
TRIM_KEYS.forEach(k=>TRIMS[k].interior.forEach(i=>{if(!seen.has(i.id)){seen.add(i.id);opts.push(i);}}));
return mobileOptGroup('Interior',opts.map(i=>{
const label=`<span class="mobile-opt-swatch" style="background:${intHex(i.id)}"><img src="${interiorURL(i.code)}" loading="lazy" onerror="this.style.display='none'"></span>${i.name}`;
const cells=TRIM_KEYS.map(k=>{
const ti=TRIMS[k].interior.find(x=>x.id===i.id);
return mobileOptCell(k,{kind:'interior',id:i.id,label:ti?(ti.price?`+${money(ti.price)}`:'Included'):'—',selected:ti&&cmpIntObj(k).id===i.id,unavailable:!ti,readonly:!!ti&&TRIMS[k].interior.length<2});
});
return mobileOptRow(label,cells);
}).join(''));
}
function mobileDriveGroup(){
/* union of every trim's selectable drives; trims with a fixed drivetrain highlight
the union row that matches their drive+motors (e.g. R2 Premium/Perf ↔ AWD dual) */
const seen=new Set(),opts=[];
TRIM_KEYS.forEach(k=>{const t=TRIMS[k];if(t.drives)t.drives.forEach(d=>{if(!seen.has(d.id)){seen.add(d.id);opts.push(d);}});});
if(!opts.length)return '';
return mobileOptGroup('Drive system',opts.map(d=>{
const cells=TRIM_KEYS.map(k=>{
const t=TRIMS[k];
if(t.drives){
const has=t.drives.find(x=>x.id===d.id);
return has
?mobileOptCell(k,{kind:'drive',id:d.id,label:has.price?`+${money(has.price)}`:'Included',selected:(S.cmpDrive[k]||t.drives[0].id)===d.id})
:mobileOptCell(k,{label:'—',unavailable:true});
}
const match=(t.drive===d.drive&&t.motors===d.motors);
return mobileOptCell(k,{label:match?'Included':'—',selected:match,unavailable:!match,readonly:true});
});
return mobileOptRow(`${d.drive} · ${d.sub}`,cells);
}).join(''));
}
function mobileAddonGroup(){
const promoOn=!S.launchOff;
const promo=hasLaunchPromo()?mobileOptRow('Launch Edition promo',TRIM_KEYS.map(k=>TRIMS[k].autoIncl
?`<button class="mobile-opt-cell${promoOn?' sel':''}" data-promo>${promoOn?ico('check',11)+' Active':'Ended · what-if'}</button>`
:mobileOptCell(k,{label:'—',unavailable:true}))):'';
if(!promo&&!CMP_ADDONS.length)return '';
return mobileOptGroup('Packages',promo+CMP_ADDONS.map(a=>{
const cells=TRIM_KEYS.map(k=>{
const inc=isLaunchInc(TRIMS[k],a);
const on=S.cmpAddons[k].has(a.id);
return mobileOptCell(k,{id:a.id,label:inc?'Launch Edition':on?'Added':`+${money(a.price)}`,selected:inc||on,readonly:inc,add:true});
});
return mobileOptRow(a.name,cells);
}).join(''));
}
function mobileConnectGroup(){
return mobileOptGroup('Connected services',['none','yearly','monthly'].map(id=>{
const label=id==='none'?'No Connect+':`${CONNECT_PLUS.name} · ${connectPlan(id).name}`;
const cells=TRIM_KEYS.map(k=>
mobileOptCell(k,{kind:'connectPlus',id,label:connectLabel(id),selected:S.cmpConnectPlus[k]===id})
);
return mobileOptRow(label,cells);
}).join(''));
}
function totalCell(k,cfg,best,anyAcc){
const cls=k===flagshipKey()?'perfcol ':'';
const receipt=cfg.acc>0
?`<div class="trcpt"><div class="trln"><span>Vehicle</span><span>${money(cfg.vehicle)}</span></div><div class="trln"><span>+ Accessories</span><span>${money(cfg.acc)}</span></div></div>`
:'';
const tag=(best?'lowest ':'')+(anyAcc?'total':'as configured');
return `<td class="${cls}"><div class="ttl">${TRIMS[k].short}</div>${receipt}<div class="ttlp${best?' best':''}">${money(cfg.price)}</div><div class="ttld">${tag}</div></td>`;
}
function buildMatrix(){
const keys=TRIM_KEYS,ncol=keys.length;
/* dynamic spec rows derived live from each column's selected drive + wheel (works for any
trim count); the vehicle-specific equipment rows come from CUR_VEHICLE.compareSpecs */
const dynSpecs=[
{l:'Availability',get:(t,d)=>d?d.avail:t.avail},
{l:'Drivetrain',get:(t,d)=>`${d?d.motors:t.motors} ${d?d.drive:t.drive}`},
{l:'Horsepower',get:(t,d)=>(d?d.hp:t.hp)+' hp'},
{l:'0–60 mph',get:(t,d)=>d?d.z60:t.z60},
{l:'__range__'},
{l:'Max towing',get:(t,d)=>d?d.tow:t.tow}
];
const dynLabel=r=>r.l==='__range__'?'EPA range':r.l;
/* one dynamic-spec cell: strike the trim's base value when the current pick changed it */
const dynCell=(k,r,cls)=>{
const t=TRIMS[k],d=cmpDriveObj(k),d0=cmpBaseDriveObj(k);
if(r.l==='__range__'){
const cur=(d?d.range:t.range)+cmpWheelObj(k).rd,base=(d0?d0.range:t.range);
return cur!==base
?`<td class="val chg${cls?' '+cls:''}"><s class="was">${base} mi</s><b class="now">${cur} mi</b></td>`
:`<td class="val${cls?' '+cls:''}">${cur} mi</td>`;
}
const cur=r.get(t,d),base=r.get(t,d0);
return cur!==base
?`<td class="val chg${cls?' '+cls:''}"><s class="was">${base}</s><b class="now">${cur}</b></td>`
:`<td class="val${cls?' '+cls:''}">${cur}</td>`;
};
/* data-driven feature rows: values are true / false / a cmpCell token ('excl2000', 'launchFob', …) */
const featVal=(r,k)=>{let v=r.values[k];if(v==='launchFob')v=!S.launchOff;return v;};
const specs=CUR_VEHICLE.compareSpecs||[],baseInc=CUR_VEHICLE.baseIncludes||[],baseLabel=CUR_VEHICLE.baseLabel||'Standard on every trim';
const specRow=r=>`<tr><td class="lab">${dynLabel(r)}</td>${keys.map(k=>dynCell(k,r,pcol(k))).join('')}</tr>`;
const featRow=r=>`<tr><td class="lab">${r.label}</td>${keys.map(k=>cmpCell(featVal(r,k),pcol(k))).join('')}</tr>`;
const baseRow=l=>`<tr><td class="lab">${l}</td>${keys.map(k=>cmpCell(true,pcol(k))).join('')}</tr>`;
const totals={};keys.forEach(k=>totals[k]=trimCfg(k));
const mobileRows=
`<div class="mobile-cmp">`
+mobileCmpHead(totals)
+mobileCmpDivider('Configure each trim')
+mobileDriveGroup()+mobileColorGroup()+mobileWheelGroup()+mobileInteriorGroup()+mobileAddonGroup()+mobileConnectGroup()
+mobileCmpDivider('Specs & equipment')
+dynSpecs.map(r=>mobileCmpRow(dynLabel(r),'',keys.map(k=>dynCell(k,r,pcol(k))))).join('')
+specs.map(r=>mobileCmpRow(r.label,'',keys.map(k=>cmpCell(featVal(r,k),pcol(k))))).join('')
+mobileCmpDivider(baseLabel)
+baseInc.map(l=>mobileCmpRow(l,'',keys.map(k=>cmpCell(true,pcol(k))))).join('')
+`</div>`;
const rows=
totalRow()
+`<tr class="divider"><td colspan="${ncol+1}">Configure each column</td></tr>`
+selRow('Drive system','drive')+selRow('Paint','color')+selRow('Wheels','wheel')+selRow('Interior','interior')
+(hasLaunchPromo()?promoRow():'')
+CMP_ADDONS.map(a=>addonRow(a.name,a.id,a.price)).join('')
+selRow('Connect+','connectPlus')
+`<tr class="divider"><td colspan="${ncol+1}">Specs & equipment</td></tr>`
+dynSpecs.map(specRow).join('')
+specs.map(featRow).join('')
+`<tr class="divider"><td colspan="${ncol+1}">${baseLabel}</td></tr>`
+baseInc.map(baseRow).join('');
const thead=`<thead><tr><th>Feature</th>${keys.map(k=>`<th class="${pcol(k)}">${TRIMS[k].short}</th>`).join('')}</tr></thead>`;
return `<div class="matrixdesk"><table class="matrix">${thead}<tbody>${rows}</tbody></table></div>${mobileRows}`;
}
function resetBuild(){
const t=curTrim();
if(t.drives)S.drive=t.drives[0].id;
S.color=t.colors[0];S.wheel=t.wheels[0].id;S.interior=t.interior[0].id;S.addons.clear();S.connectPlus='none';
S.launchOff=false;
renderAll();
}
/* reset every compare column back to its default paint, interior, drive and add-ons */
function resetCompare(){
seedCmp(); /* every column back to its default paint, interior, wheel, drive, add-ons */
S.accBundle.clear();
S.launchOff=false; /* shared with the Build tab, so refresh everything */
renderAll();
}
function updateVerdict(){
const cfg={};TRIM_KEYS.forEach(k=>cfg[k]=trimCfg(k));
const ranked=TRIM_KEYS.slice().sort((a,b)=>cfg[a].vehicle-cfg[b].vehicle);
const low=ranked[0],high=ranked[ranked.length-1];
const launchVal=CMP_ADDONS.reduce((s,a)=>s+a.price,0);
/* per-trim copy: vehicle-supplied verdictNotes (+ a launch-off variant) else a data-driven line */
const notes=CUR_VEHICLE.verdictNotes||{},notesOff=CUR_VEHICLE.verdictNotesLaunchOff||{};
const genericBody=k=>{
const t=TRIMS[k],d=cmpDriveObj(k);
const spec=`${d?d.motors:t.motors} ${d?d.drive:t.drive} · ${(d?d.range:t.range)+cmpWheelObj(k).rd} mi · ${d?d.avail:t.avail}`;
const lead=k===low?'Lowest configured price.':k===high?'Top of the range — most power, highest price.':'The middle ground on price and features.';
return `${lead} ${spec}.`;
};
/* authored notes may embed {tokens} — drive, motors, driveSub, range, hp, z60, tow,
avail — filled from the column's live drive + wheel pick, so per-vehicle copy tracks
the configuration (e.g. R2 Standard's selectable drivetrains) instead of going stale */
const fill=(tpl,k)=>{
const t=TRIMS[k],d=cmpDriveObj(k);
const v={drive:d?d.drive:t.drive,motors:d?d.motors:t.motors,driveSub:(d&&d.sub)||'',
range:(d?d.range:t.range)+cmpWheelObj(k).rd,hp:d?d.hp:t.hp,z60:d?d.z60:t.z60,
tow:d?d.tow:t.tow,avail:d?d.avail:t.avail};
return tpl.replace(/\{(\w+)\}/g,(m,key)=>v[key]!==undefined?v[key]:m);
};
const card=k=>{
const pos=k===low?'lowest':k===high?'highest':'middle';
const body=fill((S.launchOff&¬esOff[k])||notes[k]||genericBody(k),k);
return `<div class="vcard ${pos}"><div class="vtop"><span>${TRIMS[k].short}</span><b>${money(cfg[k].vehicle)}</b></div><p>${body}</p></div>`;
};
let big='';
if(ranked.length>=2){
big=`${TRIMS[ranked[1]].short} is <b>+${money(cfg[ranked[1]].vehicle-cfg[low].vehicle)}</b> over ${TRIMS[low].short}`;
if(ranked.length>=3)big+=`, then <b>+${money(cfg[high].vehicle-cfg[ranked[ranked.length-2]].vehicle)}</b> more for ${TRIMS[high].short}`;
big+='.';
}
const cards=TRIM_KEYS.map(card).join('');
const note=hasLaunchPromo()?(S.launchOff
?`<div class="vnote">Configured vehicle prices shown before shared gear and recurring services. Launch Edition promo toggled off — its ${money(launchVal)} of add-ons price individually on every trim.</div>`
:`<div class="vnote">Configured vehicle prices shown before shared gear and recurring services. On ${TRIMS[flagshipKey()].short}, the Launch Edition folds ${money(launchVal)} of add-ons into the price.</div>`)
:`<div class="vnote">Configured vehicle prices shown before shared gear and recurring services.</div>`;
$('verdictBig').innerHTML=big;$('verdictP').innerHTML=`<div class="vgrid">${cards}</div>${note}`;
}
/* ---------------- COST CALCULATOR ---------------- */
function amort(principal,aprPct,term){
const r=aprPct/100/12;const m=r===0?principal/term:principal*r/(1-Math.pow(1+r,-term));
let bal=principal,sched=[];
for(let i=0;i<term;i++){const int=bal*r;bal-=(m-int);sched.push({int,bal:Math.max(bal,0)});}
return {monthly:m,sched};
}
function dedCap(magi,th){if(magi<=th)return 10000;return Math.max(0,10000-200*Math.ceil((magi-th)/1000));}
/* ================= COST OVER TIME ================= */
S.ext=null;S.pay2='finance';S.scenarios2=[];S.cur2=null;
S.rc={ins:1,maint:1,energy:1,reg:1,prop:1};S.financeGear=false;S.hasTrade=false;
const INPUT_IDS2=['i2_price','i2_gear','i2_trade','i2_owed','i2_years','i2_miles','i2_down','i2_apr','i2_term','i2_year','i2_lease','i2_leasedown','i2_leaseterm','i2_ins','i2_maint','i2_kwh','i2_public','i2_eff','i2_home','i2_install','i2_proptax','i2_resale','i2_esc','i2_rebate','i2_mpg','i2_gas','i2_filing','i2_magi','i2_rate'];
const fmtK=n=>{const a=Math.abs(n);if(a>=1000)return (n<0?'-$':'$')+Math.round(a/1000)+'k';return (n<0?'-$':'$')+Math.round(a);};
/* ----- launch from Compare / Build into the cost tab ----- */
function buildExt(src){
/* src.k = trim key; pulls from Compare column config (paint, interior, drive, add-ons) + shared gear */
const k=src.k,t=TRIMS[k],cfg=trimCfg(k);
const colId=cfg.colId,w=cfg.wo,io=cfg.io;
const dObj=cfg.driveObj; /* set for any trim with selectable drives; null otherwise */
const addonNames=[];CMP_ADDONS.forEach(a=>{const inc=isLaunchInc(t,a);if(inc)addonNames.push(a.name+' (Launch)');else if(S.cmpAddons[k].has(a.id))addonNames.push(a.name);});
const gearItems=[];CMP_ACCESSORIES.forEach(g=>g.items.forEach(a=>{if(a.price&&S.accBundle.has(a.id))gearItems.push({name:a.name,price:a.price});}));
return {source:'compare',vehicleId:S.vehicle,vehicleName:CUR_VEHICLE.name,trim:k,trimName:t.short,folder:t.folder,colCode:COLORS[colId].code,colName:COLORS[colId].name,
wheelCode:w.code,wheelName:w.name,vehicle:cfg.vehicle,gear:cfg.acc,connectPlus:normalizeConnect(S.cmpConnectPlus[k]),
base:t.price,drive:cfg.drive,paint:cfg.paint,interior:cfg.interior,addon:cfg.addon,
driveLabel:dObj?(dObj.drive+' · '+dObj.sub):(t.motors+' '+t.drive),
intName:io.name,addonNames,gearItems,
range:(dObj?dObj.range:t.range)+w.rd,hp:dObj?dObj.hp:t.hp,
z60:dObj?dObj.z60:t.z60,avail:dObj?dObj.avail:t.avail};
}
/* Build tab → cost-over-time: source the loaded vehicle from the actual Build config
(Build has its own wheel + add-on selections and no gear bundle, unlike Compare). */
function buildExtFromBuild(){
const k=S.trim,t=curTrim(),col=COLORS[S.color],io=t.interior.find(i=>i.id===S.interior)||t.interior[0];
const dObj=curDrive(),w=curWheel();
const addonNames=[];let addon=0;
ADDONS.forEach(a=>{const inc=isLaunchInc(t,a);if(inc)addonNames.push(a.name+' (Launch)');else if(S.addons.has(a.id)){addonNames.push(a.name);addon+=a.price;}});
const gearItems=[];CMP_ACCESSORIES.forEach(g=>g.items.forEach(a=>{if(a.price&&S.accBundle.has(a.id))gearItems.push({name:a.name,price:a.price});}));
return {source:'build',vehicleId:S.vehicle,vehicleName:CUR_VEHICLE.name,trim:k,trimName:t.short,folder:t.folder,colCode:col.code,colName:col.name,
wheelCode:w.code,wheelName:w.name,vehicle:configuredPrice(),gear:accBundleTotal(),connectPlus:normalizeConnect(S.connectPlus),
base:t.price,drive:dObj?dObj.price:0,paint:col.price,interior:io.price||0,addon,
driveLabel:dObj?(dObj.drive+(dObj.sub?' · '+dObj.sub:'')):(t.motors+' '+t.drive),
intName:io.name,addonNames,gearItems,
range:curRange(),hp:curHP(),z60:dObj?dObj.z60:t.z60,avail:curAvail()};
}
function launchCost2(k){
S.ext=buildExt({k});
document.querySelector('.tab[data-tab="cost2"]').click(); /* applies + renders on entry */
}
function launchCost2FromBuild(){
S.ext=buildExtFromBuild();
document.querySelector('.tab[data-tab="cost2"]').click(); /* applies + renders on entry */
}
function ensureExt(){if(!S.ext)S.ext=buildExtFromBuild();}
function applyExt(){
ensureExt();
$('i2_price').value=Math.round(S.ext.vehicle);
$('i2_gear').value=Math.round(S.ext.gear);
renderLoaded();calc2();
}
function renderLoaded(){
const host=$('loadedCard');if(!host)return;const e=S.ext;
if(!e){host.className='loaded empty';host.innerHTML='<div class="lbody"><div class="ltrim">No vehicle loaded</div><div class="lcfg">Spec a trim on Build or Compare and hit “See cost over time.”</div></div>';return;}
host.className='loaded';
const addons=e.addonNames.length?' · '+e.addonNames.join(', '):'';
const connect=connectSummary(e.connectPlus);
const gearLine=e.gear>0?`<div class="pg">+ ${money(e.gear)} gear · ${e.gearItems.length} item${e.gearItems.length>1?'s':''}</div>`:'<div class="pg">no gear added</div>';
host.innerHTML=`<div class="lthumb"><img loading="lazy" alt="${e.trimName}" src="${heroURL(e.folder,e.wheelCode,e.colCode,e.vehicleId)}" onerror="this.parentNode.style.display='none'"></div>
<div class="lbody">
<div class="ltrim">${e.vehicleName||'R2'} ${e.trimName}</div>
<div class="lcfg"><b>${e.colName}</b> · ${e.intName} · ${e.driveLabel} · ${e.range} mi · ${e.hp} hp · 0–60 ${e.z60}${addons}${connect?' · '+connect:''}</div>
</div>
<div class="lprice"><div class="pv">${money(e.vehicle)}</div>${gearLine}</div>
<button class="lswap" data-goto="${e.source==='build'?'build':'compare'}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>Edit</button>`;
const b=host.querySelector('[data-goto]');if(b)b.onclick=()=>document.querySelector('.tab[data-tab="'+b.dataset.goto+'"]').click();
}
/* ----- chart frame helpers (offline inline SVG) ----- */
const CW=340,CH=190,CL=44,CR=12,CT=12,CB=26,PW=CW-CL-CR,PH=CH-CT-CB;
/* geometry boxes: the compact grid charts use G0 (the module constants above); the
full-width scenarios overlay uses GSCEN — a wide, short box so it doesn't tower over
the other charts. Geometry-aware helpers take an optional g, defaulting to G0. */
const G0={CW,CH,CL,CR,CT,CB,PW,PH};
const GSCEN=(()=>{const CW=760,CH=200,CL=44,CR=12,CT=12,CB=26;return{CW,CH,CL,CR,CT,CB,PW:CW-CL-CR,PH:CH-CT-CB};})();
const xM=(m,NM,g=G0)=>g.CL+(NM?m/NM:0)*g.PW;
const yV=(v,Vmax,g=G0)=>g.CT+(1-(Vmax?v/Vmax:0))*g.PH;
const lineP=pts=>pts.map((p,i)=>(i?'L':'M')+p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' ');
const areaP=(pts,baseY)=>pts.length?('M'+pts[0][0].toFixed(1)+' '+baseY.toFixed(1)+' '+pts.map(p=>'L'+p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' ')+' L'+pts[pts.length-1][0].toFixed(1)+' '+baseY.toFixed(1)+' Z'):'';
function frameSVG(inner,g=G0){return `<svg viewBox="0 0 ${g.CW} ${g.CH}" role="img">${inner}</svg>`;}
function yAxis(Vmax,g=G0){
let s='';const steps=[0,.5,1];
steps.forEach(f=>{const y=g.CT+(1-f)*g.PH,v=Vmax*f;
s+=`<line class="${f===0?'ax':'axg'}" x1="${g.CL}" y1="${y.toFixed(1)}" x2="${g.CW-g.CR}" y2="${y.toFixed(1)}"/>`;
s+=`<text class="axlbl end" x="${g.CL-5}" y="${(y+3).toFixed(1)}">${fmtK(v)}</text>`;});
return s;
}
function xYears(years,NM,g=G0){
let s='';const step=years<=8?1:2;
for(let y=0;y<=years;y+=step){const x=xM(y*12,NM,g);
s+=`<text class="axlbl mid" x="${x.toFixed(1)}" y="${g.CH-9}">${y===0?'now':y+'y'}</text>`;}
return s;
}
function legendRow(items){return `<div class="clegend">${items.map(i=>`<span class="ci"${i.k?` data-k="${i.k}"`:''}><i class="${i.ln?'ln':''}" style="background:${i.c}"></i>${i.t}</span>`).join('')}</div>`;}
/* ----- shared hover tooltip + cross-highlighting for segmented bars ----- */
let TIP=null;
function tipEl(){if(!TIP){TIP=document.createElement('div');TIP.className='ctip';TIP.setAttribute('aria-hidden','true');document.body.appendChild(TIP);}return TIP;}
function showTip(html,x,y){const el=tipEl();el.innerHTML=html;el.classList.add('show');
const r=el.getBoundingClientRect();
el.style.left=Math.min(Math.max(8,x+14),window.innerWidth-r.width-8)+'px';
el.style.top=Math.max(8,y-r.height-12)+'px';}
function hideTip(){if(TIP)TIP.classList.remove('show');}
function bindSegTips(host,sel){
if(!host)return;
host.querySelectorAll(sel).forEach(el=>{
el.addEventListener('mousemove',e=>{if(el.dataset.tip)showTip(el.dataset.tip,e.clientX,e.clientY);});
el.addEventListener('mouseleave',hideTip);
});
}
/* crosshair hover for the line charts: track the month under the cursor, draw a
dashed guide + dots on the curves, and reuse the shared tooltip.
probe(m) → {tip, dots:[[svgY, color], …]} or null to clear. */
function bindChartHover(host,NM,probe,mLo,g=G0){
if(!host)return;const svg=host.querySelector('svg');if(!svg)return;
const lo=(mLo==null)?1:mLo;
const ov=document.createElementNS('http://www.w3.org/2000/svg','g');
ov.setAttribute('style','pointer-events:none');
svg.appendChild(ov);
svg.addEventListener('mousemove',e=>{
const r=svg.getBoundingClientRect();if(!r.width)return;
const vx=(e.clientX-r.left)*(g.CW/r.width);
let m=Math.round((vx-g.CL)/g.PW*NM);m=Math.max(lo,Math.min(NM,m));
const p=probe(m);
if(!p){ov.innerHTML='';hideTip();return;}
const x=xM(m,NM,g).toFixed(1);
ov.innerHTML=`<line x1="${x}" y1="${g.CT}" x2="${x}" y2="${g.CT+g.PH}" stroke="var(--faint)" opacity=".55" stroke-dasharray="2 2"/>`+
p.dots.map(d=>`<circle cx="${x}" cy="${d[0].toFixed(1)}" r="3" fill="${d[1]}" stroke="var(--panel)" stroke-width="1.2"/>`).join('');
showTip(p.tip,e.clientX,e.clientY);
});
svg.addEventListener('mouseleave',()=>{ov.innerHTML='';hideTip();});
}
const moLabel=m=>`Month ${m} · year ${Math.ceil(m/12)}`;
/* legend item hover → spotlight that category's segments (dim the rest) */
function bindLegendHighlight(host,segSel){
if(!host)return;
host.querySelectorAll('.clegend .ci[data-k]').forEach(ci=>{
ci.addEventListener('mouseenter',()=>host.querySelectorAll(segSel).forEach(sg=>sg.classList.toggle('dim',sg.dataset.k!==ci.dataset.k)));
ci.addEventListener('mouseleave',()=>host.querySelectorAll(segSel).forEach(sg=>sg.classList.remove('dim')));
});
}
/* ----- the model ----- */
/* selected-state helpers: LOC = the STATES row driving per-state tax/fees/defaults */
const locRow=()=>STATES[S.state2]||STATES.NC;
function syncPropRow(){$('i2_proptaxRow').style.display=(locRow().propTax===0)?'none':'';}
/* live "what this state sets" summary under the picker — the upfront tax % has no
field of its own, so this makes the state's effect visible at the point of choice */
function renderStateSets(){
const L=locRow(),el=$('i2_stateSets');if(!el)return;
const reg=L.evFee>0?`$${Math.round(L.reg)} + $${Math.round(L.evFee)} reg/EV per yr`:`$${Math.round(L.reg)} reg per yr`;
const prop=L.propTax>0?`${L.propTax}/$100 property tax`:'no property tax';
el.innerHTML=`<b>${L.name}</b> sets <b>${L.tax}%</b> upfront tax · <b>$${L.title}</b> title · <b>${reg}</b> · <b>${prop}</b> · <b>~${L.kwh}¢</b>/kWh home power`;
}
function applyStateDefaults(){const L=locRow();$('i2_ins').value=L.ins;$('i2_proptax').value=L.propTax;
if($('i2_kwh'))$('i2_kwh').value=L.kwh;if($('i2_gas'))$('i2_gas').value=L.gas;
syncPropRow();renderStateSets();}
/* value-curve constants: front-loaded two-phase depreciation + mileage-aware endpoint */
const DEP_R1=0.82; /* default year-1 retention (drive-off + first-year drop) */
const DEP_MI_BASE=12000; /* mi/yr baseline the resale-% input assumes */