@jjlmoya/utils-motor 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/category/index.ts +2 -1
- package/src/entries.ts +2 -1
- package/src/index.ts +5 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/evChargingTimeCostPlanner/bibliography.astro +6 -0
- package/src/tool/evChargingTimeCostPlanner/bibliography.ts +16 -0
- package/src/tool/evChargingTimeCostPlanner/component.astro +147 -0
- package/src/tool/evChargingTimeCostPlanner/controller.ts +214 -0
- package/src/tool/evChargingTimeCostPlanner/dom-views.ts +124 -0
- package/src/tool/evChargingTimeCostPlanner/entry.ts +30 -0
- package/src/tool/evChargingTimeCostPlanner/ev-charging-time-cost-planner.css +598 -0
- package/src/tool/evChargingTimeCostPlanner/evaluator.ts +10 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/de.ts +22 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/en.ts +161 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/es.ts +16 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/factory.ts +113 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/fr.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/id.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/it.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/ja.ts +13 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/ko.ts +13 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/nl.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/pl.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/pt.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/ru.ts +13 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/sv.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/tr.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/zh.ts +13 -0
- package/src/tool/evChargingTimeCostPlanner/index.ts +10 -0
- package/src/tool/evChargingTimeCostPlanner/logic.test.ts +71 -0
- package/src/tool/evChargingTimeCostPlanner/logic.ts +174 -0
- package/src/tool/evChargingTimeCostPlanner/seo.astro +15 -0
- package/src/tool/evChargingTimeCostPlanner/storage.ts +27 -0
- package/src/tool/evChargingTimeCostPlanner/ui.ts +50 -0
- package/src/tools.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-motor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"check": "astro check",
|
|
29
29
|
"type-check": "astro check",
|
|
30
30
|
"test": "vitest run",
|
|
31
|
-
"preversion": "npm run lint && npm run test
|
|
31
|
+
"preversion": "npm run lint && npm run test",
|
|
32
32
|
"postversion": "git push && git push --tags",
|
|
33
33
|
"patch": "npm version patch",
|
|
34
34
|
"minor": "npm version minor",
|
package/src/category/index.ts
CHANGED
|
@@ -4,10 +4,11 @@ import { carMotorcycleGearRatioCalculator } from '../tool/carMotorcycleGearRatio
|
|
|
4
4
|
import { motorcycleSkidMarkSpeedEstimator } from '../tool/motorcycleSkidMarkSpeedEstimator/entry';
|
|
5
5
|
import { realFuelConsumptionCalculator } from '../tool/realFuelConsumptionCalculator/entry';
|
|
6
6
|
import { tirePressureConverter } from '../tool/tirePressureConverter/entry';
|
|
7
|
+
import { evChargingTimeCostPlanner } from '../tool/evChargingTimeCostPlanner/entry';
|
|
7
8
|
|
|
8
9
|
export const motorCategory: MotorCategoryEntry = {
|
|
9
10
|
icon: 'mdi:car-cog',
|
|
10
|
-
tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator] as unknown as MotorToolEntry<Record<string, string>>[],
|
|
11
|
+
tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner] as unknown as MotorToolEntry<Record<string, string>>[],
|
|
11
12
|
i18n: {
|
|
12
13
|
es: () => import('./i18n/es').then((module) => module.content),
|
|
13
14
|
en: () => import('./i18n/en').then((module) => module.content),
|
package/src/entries.ts
CHANGED
|
@@ -15,5 +15,6 @@ import { carMotorcycleGearRatioCalculator } from './tool/carMotorcycleGearRatioC
|
|
|
15
15
|
import { motorcycleSkidMarkSpeedEstimator } from './tool/motorcycleSkidMarkSpeedEstimator/entry';
|
|
16
16
|
import { realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalculator/entry';
|
|
17
17
|
import { tirePressureConverter } from './tool/tirePressureConverter/entry';
|
|
18
|
+
import { evChargingTimeCostPlanner } from './tool/evChargingTimeCostPlanner/entry';
|
|
18
19
|
|
|
19
|
-
export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator];
|
|
20
|
+
export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner];
|
package/src/index.ts
CHANGED
|
@@ -41,3 +41,8 @@ export type {
|
|
|
41
41
|
MotorcycleSkidMarkSpeedEstimatorUI,
|
|
42
42
|
MotorcycleSkidMarkSpeedEstimatorLocaleContent,
|
|
43
43
|
} from './tool/motorcycleSkidMarkSpeedEstimator';
|
|
44
|
+
export { EV_CHARGING_TIME_COST_PLANNER_TOOL, evChargingTimeCostPlanner } from './tool/evChargingTimeCostPlanner';
|
|
45
|
+
export type {
|
|
46
|
+
EVChargingTimeCostPlannerUI,
|
|
47
|
+
EVChargingTimeCostPlannerLocaleContent,
|
|
48
|
+
} from './tool/evChargingTimeCostPlanner';
|
|
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../types';
|
|
|
4
4
|
|
|
5
5
|
describe('Locale Completeness and Slug Validation', () => {
|
|
6
6
|
it('all tools registered in ALL_TOOLS', () => {
|
|
7
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
expect(ALL_TOOLS.length).toBe(6);
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
ALL_TOOLS.forEach((tool) => {
|
|
@@ -5,7 +5,7 @@ import { motorCategory } from '../data';
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
7
|
it('should have tools registered in ALL_TOOLS', () => {
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(6);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('motorCategory should be defined', () => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliographyEntries: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'IEA, Electric vehicle charging, Global EV Outlook 2025',
|
|
6
|
+
url: 'https://www.iea.org/reports/global-ev-outlook-2025/electric-vehicle-charging',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'IDAE, Guía de movilidad eléctrica para las entidades locales',
|
|
10
|
+
url: 'https://www.idae.es/sites/default/files/movilidad/2019-10/Guia_movilidad_electrica_para_entidades_locales.pdf',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'European Central Bank, Euro foreign exchange reference rates',
|
|
14
|
+
url: 'https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './ev-charging-time-cost-planner.css';
|
|
3
|
+
import { CHARGING_PRESETS, CURRENCY_OPTIONS, convertCurrencyAmount, getCurrencyOption, isCurrencyCode } from './logic';
|
|
4
|
+
import type { EVChargingTimeCostPlannerUI } from './ui';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
ui: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ui = Astro.props.ui as EVChargingTimeCostPlannerUI;
|
|
11
|
+
const initialCurrency = isCurrencyCode(ui.defaultCurrency) ? ui.defaultCurrency : 'EUR';
|
|
12
|
+
const initialCurrencyOption = getCurrencyOption(initialCurrency);
|
|
13
|
+
const initialPreset = CHARGING_PRESETS[0]!;
|
|
14
|
+
const initialPrice = convertCurrencyAmount(initialPreset.inputs.pricePerKwh, initialPreset.inputs.currency, initialCurrency);
|
|
15
|
+
const formatPrice = (value: number): string => value.toFixed(2);
|
|
16
|
+
const initialCost = new Intl.NumberFormat('en-US', { style: 'currency', currency: initialCurrency, maximumFractionDigits: 2 }).format(32 * Number(formatPrice(initialPrice)));
|
|
17
|
+
const presetMeta = (preset: typeof initialPreset): string => ui.presetMetaTemplate
|
|
18
|
+
.replace('{power}', preset.inputs.chargerPowerKw.toFixed(1))
|
|
19
|
+
.replace('{symbol}', initialCurrencyOption.symbol)
|
|
20
|
+
.replace('{price}', formatPrice(convertCurrencyAmount(preset.inputs.pricePerKwh, preset.inputs.currency, initialCurrency)));
|
|
21
|
+
const getInitialSegmentLevel = (index: number): string => {
|
|
22
|
+
if (index < 4) return 'held';
|
|
23
|
+
if (index < 10) return 'added';
|
|
24
|
+
return 'empty';
|
|
25
|
+
};
|
|
26
|
+
const fields = [
|
|
27
|
+
{ id: 'capacityKwh', label: ui.capacityLabel, hint: ui.capacityHint, value: '60', min: '1', max: '250', step: '1', unit: 'kWh' },
|
|
28
|
+
{ id: 'chargerPowerKw', label: ui.chargerPowerLabel, hint: ui.chargerPowerHint, value: '7.4', min: '0.1', max: '350', step: '0.1', unit: 'kW' },
|
|
29
|
+
{ id: 'efficiency', label: ui.efficiencyLabel, hint: ui.efficiencyHint, value: '90', min: '50', max: '100', step: '1', unit: '%' },
|
|
30
|
+
{ id: 'pricePerKwh', label: ui.priceLabel, hint: ui.priceHint, value: formatPrice(initialPrice), min: '0', max: String(5 * initialCurrencyOption.unitsPerEur), step: '0.01', unit: `${initialCurrencyOption.symbol} / kWh` },
|
|
31
|
+
{ id: 'consumptionKwhPer100Km', label: ui.consumptionLabel, hint: ui.consumptionHint, value: '18', min: '1', max: '100', step: '0.5', unit: 'kWh / 100 km' },
|
|
32
|
+
];
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
<div class="evc-tool" lang="en-US" data-ev-charging-planner data-currency={initialCurrency} data-status="standard">
|
|
36
|
+
<section class="evc-surface">
|
|
37
|
+
<header class="evc-header">
|
|
38
|
+
<div class="evc-header-copy">
|
|
39
|
+
<p class="evc-overline">{ui.setupLabel}</p>
|
|
40
|
+
<p class="evc-intro">{ui.setupHint}</p>
|
|
41
|
+
</div>
|
|
42
|
+
<button class="evc-reset" type="button" data-ev-reset>{ui.resetLabel}</button>
|
|
43
|
+
</header>
|
|
44
|
+
|
|
45
|
+
<div class="evc-scenes" role="group" aria-label={ui.presetLabel}>
|
|
46
|
+
<span class="evc-scenes-label">{ui.presetLabel}</span>
|
|
47
|
+
<button type="button" class="evc-scene-button is-active" data-ev-preset="home" aria-pressed="true"><strong>{ui.homePreset}</strong><small data-ev-preset-meta="home">{presetMeta(CHARGING_PRESETS[0]!)}</small></button>
|
|
48
|
+
<button type="button" class="evc-scene-button" data-ev-preset="public" aria-pressed="false"><strong>{ui.publicPreset}</strong><small data-ev-preset-meta="public">{presetMeta(CHARGING_PRESETS[1]!)}</small></button>
|
|
49
|
+
<button type="button" class="evc-scene-button" data-ev-preset="fast" aria-pressed="false"><strong>{ui.fastPreset}</strong><small data-ev-preset-meta="fast">{presetMeta(CHARGING_PRESETS[2]!)}</small></button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="evc-main-grid">
|
|
53
|
+
<section class="evc-inputs" aria-labelledby="evc-inputs-title">
|
|
54
|
+
<div class="evc-section-heading">
|
|
55
|
+
<div>
|
|
56
|
+
<p class="evc-overline">{ui.chargeWindowLabel}</p>
|
|
57
|
+
<h2 id="evc-inputs-title">{ui.chargeWindowHint}</h2>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="evc-soc-row">
|
|
62
|
+
<label class="evc-field evc-soc-field" for="evc-start-number">
|
|
63
|
+
<span>{ui.startSocLabel}</span><small>{ui.startSocHint}</small>
|
|
64
|
+
<strong><input id="evc-start-number" data-ev-field="startSoc" data-ev-number type="number" min="0" max="100" step="1" value="32" /><em>%</em></strong>
|
|
65
|
+
<input data-ev-field="startSoc" data-ev-range type="range" min="0" max="100" step="1" value="32" aria-label={ui.startSocLabel} />
|
|
66
|
+
</label>
|
|
67
|
+
<div class="evc-window-arrow" aria-hidden="true">→</div>
|
|
68
|
+
<label class="evc-field evc-soc-field" for="evc-end-number">
|
|
69
|
+
<span>{ui.endSocLabel}</span><small>{ui.endSocHint}</small>
|
|
70
|
+
<strong><input id="evc-end-number" data-ev-field="endSoc" data-ev-number type="number" min="0" max="100" step="1" value="80" /><em>%</em></strong>
|
|
71
|
+
<input data-ev-field="endSoc" data-ev-range type="range" min="0" max="100" step="1" value="80" aria-label={ui.endSocLabel} />
|
|
72
|
+
</label>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="evc-currency-panel" data-ev-currency-select>
|
|
76
|
+
<div>
|
|
77
|
+
<span>{ui.currencyLabel}</span>
|
|
78
|
+
<small>{ui.currencyHint}</small>
|
|
79
|
+
</div>
|
|
80
|
+
<button type="button" class="evc-currency-trigger" data-ev-currency-trigger aria-haspopup="listbox" aria-expanded="false" aria-label={ui.currencyMenuLabel}>
|
|
81
|
+
<strong data-ev-currency-code>{initialCurrency}</strong>
|
|
82
|
+
<span data-ev-currency-factor>{ui.currencyFactorTemplate.replace('{factor}', initialCurrencyOption.unitsPerEur.toString()).replace('{currency}', initialCurrency)}</span>
|
|
83
|
+
<i aria-hidden="true"></i>
|
|
84
|
+
</button>
|
|
85
|
+
<div class="evc-currency-menu" data-ev-currency-menu role="listbox" aria-label={ui.currencyMenuLabel} hidden>
|
|
86
|
+
{CURRENCY_OPTIONS.map((option) => (
|
|
87
|
+
<button type="button" role="option" data-ev-currency-option={option.code} aria-selected={option.code === initialCurrency ? 'true' : 'false'}>
|
|
88
|
+
<strong>{option.code}</strong><span>{option.symbol} · 1 EUR = {option.unitsPerEur}</span>
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div class="evc-field-stack">
|
|
95
|
+
{fields.map((field) => (
|
|
96
|
+
<label class="evc-field" for={`evc-${field.id}-number`}>
|
|
97
|
+
<span data-ev-price-label={field.id === 'pricePerKwh' ? '' : undefined}>{field.label}</span><small>{field.hint}</small>
|
|
98
|
+
<strong><input id={`evc-${field.id}-number`} data-ev-field={field.id} data-ev-number type={field.id === 'pricePerKwh' ? 'text' : 'number'} inputmode={field.id === 'pricePerKwh' ? 'decimal' : undefined} min={field.min} max={field.max} step={field.step} value={field.value} /><em data-ev-price-unit={field.id === 'pricePerKwh' ? '' : undefined}>{field.unit}</em></strong>
|
|
99
|
+
<input data-ev-field={field.id} data-ev-range type="range" min={field.min} max={field.max} step={field.step} value={field.value} aria-label={field.label} />
|
|
100
|
+
</label>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<section class="evc-runway" aria-labelledby="evc-result-title" aria-live="polite">
|
|
106
|
+
<div class="evc-runway-heading">
|
|
107
|
+
<div><p class="evc-overline">{ui.resultKicker}</p><h2 id="evc-result-title">{ui.resultTitle}</h2></div>
|
|
108
|
+
<span class="evc-status" data-ev-status>{ui.statusStandard}</span>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="evc-battery-wrap">
|
|
111
|
+
<svg class="evc-battery" viewBox="0 0 540 206" role="img" aria-labelledby="evc-battery-title">
|
|
112
|
+
<title id="evc-battery-title">{ui.batterySceneLabel}</title>
|
|
113
|
+
<rect class="evc-battery-shell" x="16" y="35" width="470" height="136" rx="28" />
|
|
114
|
+
<rect class="evc-battery-terminal" x="486" y="81" width="38" height="44" rx="12" />
|
|
115
|
+
<g class="evc-segments">
|
|
116
|
+
{Array.from({ length: 12 }, (_, index) => <rect data-ev-segment data-level={getInitialSegmentLevel(index)} x={38 + index * 36} y="56" width="25" height="94" rx="10" />)}
|
|
117
|
+
</g>
|
|
118
|
+
<path class="evc-energy-line" d="M74 184 C146 198 244 199 330 184 S444 164 500 190" />
|
|
119
|
+
<circle class="evc-energy-node" cx="74" cy="184" r="7" />
|
|
120
|
+
<circle class="evc-energy-node" cx="500" cy="190" r="7" />
|
|
121
|
+
</svg>
|
|
122
|
+
<div class="evc-battery-legend"><span><i class="evc-dot evc-dot-held"></i><span>{ui.startingChargeLabel}</span> <strong data-ev-start-soc>32%</strong></span><span><i class="evc-dot evc-dot-added"></i><span>{ui.targetChargeLabel}</span> <strong data-ev-end-soc>80%</strong></span></div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div class="evc-result-lead"><span>{ui.timeLabel}</span><strong data-ev-time>4 h 20 min</strong><small data-ev-power>7.4 kW</small></div>
|
|
126
|
+
<div class="evc-metrics">
|
|
127
|
+
<div><span>{ui.batteryEnergyLabel}</span><strong data-ev-battery-energy>28.8 kWh</strong></div>
|
|
128
|
+
<div><span>{ui.gridEnergyLabel}</span><strong data-ev-grid-energy>32 kWh</strong></div>
|
|
129
|
+
<div><span>{ui.lossesLabel}</span><strong data-ev-loss-energy>3.2 kWh</strong></div>
|
|
130
|
+
<div><span>{ui.costLabel}</span><strong data-ev-cost>{initialCost}</strong></div>
|
|
131
|
+
<div><span>{ui.rangeLabel}</span><strong data-ev-result-range>160 km</strong></div>
|
|
132
|
+
</div>
|
|
133
|
+
<p class="evc-summary" data-ev-summary>{ui.summaryTemplate.replace('{battery}', '28.8').replace('{range}', '160').replace('{power}', '7.4').replace('{time}', '4 h 20 min').replace('{cost}', initialCost)}</p>
|
|
134
|
+
</section>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<footer class="evc-formula"><strong>{ui.formulaLabel}</strong><span>{ui.formulaText}</span></footer>
|
|
138
|
+
</section>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<script is:inline id="evc-copy" type="application/json" set:html={JSON.stringify(ui)}></script>
|
|
142
|
+
<script>
|
|
143
|
+
import { initEVChargingPlanner } from './controller';
|
|
144
|
+
|
|
145
|
+
const copy = document.querySelector<HTMLScriptElement>('#evc-copy');
|
|
146
|
+
if (copy?.textContent) initEVChargingPlanner(JSON.parse(copy.textContent));
|
|
147
|
+
</script>
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHARGING_PRESETS,
|
|
3
|
+
calculateChargingPlan,
|
|
4
|
+
convertCurrencyAmount,
|
|
5
|
+
DEFAULT_CURRENCY,
|
|
6
|
+
getCurrencyOption,
|
|
7
|
+
getPriceInputStep,
|
|
8
|
+
isCurrencyCode,
|
|
9
|
+
type ChargingInputs,
|
|
10
|
+
type CurrencyCode,
|
|
11
|
+
} from './logic';
|
|
12
|
+
import { evaluateChargingStatus } from './evaluator';
|
|
13
|
+
import { clearChargingInputs, loadChargingInputs, saveChargingInputs } from './storage';
|
|
14
|
+
import { renderChargingPlan } from './dom-views';
|
|
15
|
+
import type { EVChargingTimeCostPlannerUI } from './ui';
|
|
16
|
+
|
|
17
|
+
type NumericField = Exclude<keyof ChargingInputs, 'currency'>;
|
|
18
|
+
|
|
19
|
+
const fields: NumericField[] = [
|
|
20
|
+
'capacityKwh',
|
|
21
|
+
'startSoc',
|
|
22
|
+
'endSoc',
|
|
23
|
+
'chargerPowerKw',
|
|
24
|
+
'efficiency',
|
|
25
|
+
'pricePerKwh',
|
|
26
|
+
'consumptionKwhPer100Km',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function getRootCurrency(root: HTMLElement): CurrencyCode {
|
|
30
|
+
return isCurrencyCode(root.dataset.evCurrency) ? root.dataset.evCurrency : DEFAULT_CURRENCY;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readInputs(root: HTMLElement): ChargingInputs {
|
|
34
|
+
const values = fields.map((field) => {
|
|
35
|
+
const input = root.querySelector<HTMLInputElement>(`[data-ev-field="${field}"][data-ev-number]`);
|
|
36
|
+
const rawValue = Number((input?.value ?? '0').replace(',', '.'));
|
|
37
|
+
const value = field === 'efficiency' ? rawValue / 100 : rawValue;
|
|
38
|
+
return [field, value] as const;
|
|
39
|
+
});
|
|
40
|
+
return { ...Object.fromEntries(values), currency: getRootCurrency(root) } as unknown as ChargingInputs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setField(root: HTMLElement, field: NumericField, value: number): void {
|
|
44
|
+
const displayValue = getDisplayValue(field, value);
|
|
45
|
+
root.querySelectorAll<HTMLInputElement>(`[data-ev-field="${field}"]`).forEach((input) => {
|
|
46
|
+
input.value = String(displayValue);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getDisplayValue(field: NumericField, value: number): number {
|
|
51
|
+
if (field === 'efficiency') return value * 100;
|
|
52
|
+
if (field === 'pricePerKwh') return Number(value.toFixed(2));
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setInputs(root: HTMLElement, inputs: ChargingInputs): void {
|
|
57
|
+
fields.forEach((field) => setField(root, field, inputs[field]));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setCurrencyMenuOpen(root: HTMLElement, open: boolean): void {
|
|
61
|
+
const menu = root.querySelector<HTMLElement>('[data-ev-currency-menu]');
|
|
62
|
+
const trigger = root.querySelector<HTMLButtonElement>('[data-ev-currency-trigger]');
|
|
63
|
+
if (menu) menu.hidden = !open;
|
|
64
|
+
if (trigger) trigger.setAttribute('aria-expanded', String(open));
|
|
65
|
+
root.classList.toggle('is-currency-open', open);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function updatePriceInputBounds(root: HTMLElement, currency: CurrencyCode): void {
|
|
69
|
+
const factor = getCurrencyOption(currency).unitsPerEur;
|
|
70
|
+
const max = String(Number((5 * factor).toFixed(2)));
|
|
71
|
+
const step = String(getPriceInputStep(currency));
|
|
72
|
+
root.querySelectorAll<HTMLInputElement>('[data-ev-field="pricePerKwh"]').forEach((input) => {
|
|
73
|
+
input.max = max;
|
|
74
|
+
input.step = step;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setCurrency(root: HTMLElement, currency: CurrencyCode): void {
|
|
79
|
+
root.dataset.evCurrency = currency;
|
|
80
|
+
updatePriceInputBounds(root, currency);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getInitialInputs(root: HTMLElement, stored: Partial<ChargingInputs> | null, defaultCurrency: CurrencyCode): ChargingInputs {
|
|
84
|
+
const preset = CHARGING_PRESETS[0]!.inputs;
|
|
85
|
+
if (stored) {
|
|
86
|
+
const currency = isCurrencyCode(stored.currency) ? stored.currency : preset.currency;
|
|
87
|
+
return { ...preset, ...stored, currency } as ChargingInputs;
|
|
88
|
+
}
|
|
89
|
+
const pricePerKwh = convertCurrencyAmount(preset.pricePerKwh, preset.currency, defaultCurrency);
|
|
90
|
+
setCurrency(root, defaultCurrency);
|
|
91
|
+
return { ...preset, currency: defaultCurrency, pricePerKwh };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getPresetInputs(currency: CurrencyCode, preset: ChargingInputs): ChargingInputs {
|
|
95
|
+
return {
|
|
96
|
+
...preset,
|
|
97
|
+
currency,
|
|
98
|
+
pricePerKwh: convertCurrencyAmount(preset.pricePerKwh, preset.currency, currency),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function syncFieldInputs(root: HTMLElement, target: HTMLInputElement): void {
|
|
103
|
+
const field = target.dataset.evField;
|
|
104
|
+
if (!field) return;
|
|
105
|
+
const normalizedValue = target.value.replace(',', '.');
|
|
106
|
+
root.querySelectorAll<HTMLInputElement>(`[data-ev-field="${field}"]`).forEach((input) => {
|
|
107
|
+
input.value = normalizedValue;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function updatePresetState(root: HTMLElement, activeId: string): void {
|
|
112
|
+
root.querySelectorAll<HTMLButtonElement>('[data-ev-preset]').forEach((button) => {
|
|
113
|
+
const active = button.dataset.evPreset === activeId;
|
|
114
|
+
button.classList.toggle('is-active', active);
|
|
115
|
+
button.setAttribute('aria-pressed', String(active));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function render(root: HTMLElement, ui: EVChargingTimeCostPlannerUI): void {
|
|
120
|
+
const inputs = readInputs(root);
|
|
121
|
+
const plan = calculateChargingPlan(inputs);
|
|
122
|
+
renderChargingPlan(root, { ui, inputs, plan, status: evaluateChargingStatus(plan) });
|
|
123
|
+
saveChargingInputs(inputs);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function attachInputHandlers(root: HTMLElement, ui: EVChargingTimeCostPlannerUI): void {
|
|
127
|
+
root.addEventListener('input', (event) => {
|
|
128
|
+
const target = event.target as HTMLInputElement;
|
|
129
|
+
if (!target.matches('[data-ev-field]')) return;
|
|
130
|
+
syncFieldInputs(root, target);
|
|
131
|
+
updatePresetState(root, 'custom');
|
|
132
|
+
render(root, ui);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function handleCurrencySelection(root: HTMLElement, ui: EVChargingTimeCostPlannerUI, currency: CurrencyCode): void {
|
|
137
|
+
const current = readInputs(root);
|
|
138
|
+
const convertedPrice = convertCurrencyAmount(current.pricePerKwh, current.currency, currency);
|
|
139
|
+
setCurrency(root, currency);
|
|
140
|
+
setField(root, 'pricePerKwh', convertedPrice);
|
|
141
|
+
updatePresetState(root, 'custom');
|
|
142
|
+
setCurrencyMenuOpen(root, false);
|
|
143
|
+
render(root, ui);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function attachCurrencyDismissal(root: HTMLElement): void {
|
|
147
|
+
document.addEventListener('click', (event) => {
|
|
148
|
+
if (!root.contains(event.target as Node)) setCurrencyMenuOpen(root, false);
|
|
149
|
+
});
|
|
150
|
+
root.addEventListener('keydown', (event) => {
|
|
151
|
+
if (event.key === 'Escape') setCurrencyMenuOpen(root, false);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function handleCurrencyClick(root: HTMLElement, ui: EVChargingTimeCostPlannerUI, target: HTMLElement): boolean {
|
|
156
|
+
const currencyOption = target.closest<HTMLButtonElement>('[data-ev-currency-option]');
|
|
157
|
+
if (currencyOption && isCurrencyCode(currencyOption.dataset.evCurrencyOption)) {
|
|
158
|
+
handleCurrencySelection(root, ui, currencyOption.dataset.evCurrencyOption);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
const currencyTrigger = target.closest<HTMLButtonElement>('[data-ev-currency-trigger]');
|
|
162
|
+
if (!currencyTrigger) return false;
|
|
163
|
+
const menu = root.querySelector<HTMLElement>('[data-ev-currency-menu]');
|
|
164
|
+
setCurrencyMenuOpen(root, Boolean(menu?.hidden));
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handlePresetClick(root: HTMLElement, ui: EVChargingTimeCostPlannerUI, target: HTMLElement): boolean {
|
|
169
|
+
const presetButton = target.closest<HTMLButtonElement>('[data-ev-preset]');
|
|
170
|
+
if (!presetButton?.dataset.evPreset) return false;
|
|
171
|
+
const preset = CHARGING_PRESETS.find((item) => item.id === presetButton.dataset.evPreset);
|
|
172
|
+
if (!preset) return true;
|
|
173
|
+
const currency = getRootCurrency(root);
|
|
174
|
+
setInputs(root, getPresetInputs(currency, preset.inputs));
|
|
175
|
+
updatePresetState(root, preset.id);
|
|
176
|
+
render(root, ui);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function handleResetClick(root: HTMLElement, ui: EVChargingTimeCostPlannerUI, target: HTMLElement): void {
|
|
181
|
+
const resetButton = target.closest<HTMLButtonElement>('[data-ev-reset]');
|
|
182
|
+
if (!resetButton) return;
|
|
183
|
+
clearChargingInputs();
|
|
184
|
+
const currency = isCurrencyCode(ui.defaultCurrency) ? ui.defaultCurrency : DEFAULT_CURRENCY;
|
|
185
|
+
setCurrency(root, currency);
|
|
186
|
+
setInputs(root, getPresetInputs(currency, CHARGING_PRESETS[0]!.inputs));
|
|
187
|
+
updatePresetState(root, 'home');
|
|
188
|
+
render(root, ui);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function attachClickHandlers(root: HTMLElement, ui: EVChargingTimeCostPlannerUI): void {
|
|
192
|
+
root.addEventListener('click', (event) => {
|
|
193
|
+
const target = event.target as HTMLElement;
|
|
194
|
+
if (handleCurrencyClick(root, ui, target)) return;
|
|
195
|
+
if (handlePresetClick(root, ui, target)) return;
|
|
196
|
+
handleResetClick(root, ui, target);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function initEVChargingPlanner(ui: EVChargingTimeCostPlannerUI): void {
|
|
201
|
+
const root = document.querySelector<HTMLElement>('[data-ev-charging-planner]');
|
|
202
|
+
if (!root || root.dataset.initialized === 'true') return;
|
|
203
|
+
root.dataset.initialized = 'true';
|
|
204
|
+
const stored = loadChargingInputs();
|
|
205
|
+
const defaultCurrency = isCurrencyCode(ui.defaultCurrency) ? ui.defaultCurrency : DEFAULT_CURRENCY;
|
|
206
|
+
const initial = getInitialInputs(root, stored, defaultCurrency);
|
|
207
|
+
setCurrency(root, initial.currency);
|
|
208
|
+
setInputs(root, initial);
|
|
209
|
+
updatePresetState(root, stored ? 'custom' : 'home');
|
|
210
|
+
render(root, ui);
|
|
211
|
+
attachInputHandlers(root, ui);
|
|
212
|
+
attachClickHandlers(root, ui);
|
|
213
|
+
attachCurrencyDismissal(root);
|
|
214
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { CHARGING_PRESETS, getCurrencyOption, type ChargingInputs, type ChargingPlan } from './logic';
|
|
2
|
+
import type { ChargingStatus } from './evaluator';
|
|
3
|
+
import type { EVChargingTimeCostPlannerUI } from './ui';
|
|
4
|
+
|
|
5
|
+
const numberFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
|
6
|
+
const priceFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
|
7
|
+
const factorFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 4 });
|
|
8
|
+
|
|
9
|
+
export function formatNumber(value: number): string {
|
|
10
|
+
return numberFormatter.format(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatCurrency(value: number, currency: ChargingInputs['currency']): string {
|
|
14
|
+
return new Intl.NumberFormat('en-US', {
|
|
15
|
+
style: 'currency',
|
|
16
|
+
currency,
|
|
17
|
+
maximumFractionDigits: 2,
|
|
18
|
+
}).format(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatPrice(value: number): string {
|
|
22
|
+
return priceFormatter.format(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatFactor(value: number): string {
|
|
26
|
+
return factorFormatter.format(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatDuration(minutes: number): string {
|
|
30
|
+
if (minutes <= 0) return '0 min';
|
|
31
|
+
const hours = Math.floor(minutes / 60);
|
|
32
|
+
const remainder = minutes % 60;
|
|
33
|
+
if (hours === 0) return `${remainder} min`;
|
|
34
|
+
if (remainder === 0) return `${hours} h`;
|
|
35
|
+
return `${hours} h ${remainder} min`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function statusText(ui: EVChargingTimeCostPlannerUI, status: ChargingStatus): string {
|
|
39
|
+
const labels: Record<ChargingStatus, string> = {
|
|
40
|
+
empty: ui.statusEmpty,
|
|
41
|
+
short: ui.statusShort,
|
|
42
|
+
standard: ui.statusStandard,
|
|
43
|
+
long: ui.statusLong,
|
|
44
|
+
};
|
|
45
|
+
return labels[status];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function updateText(root: HTMLElement, selector: string, value: string): void {
|
|
49
|
+
const element = root.querySelector<HTMLElement>(selector);
|
|
50
|
+
if (element) element.textContent = value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function updateCurrencyContext(root: HTMLElement, ui: EVChargingTimeCostPlannerUI, inputs: ChargingInputs): void {
|
|
54
|
+
const currency = getCurrencyOption(inputs.currency);
|
|
55
|
+
updateText(root, '[data-ev-currency-code]', currency.code);
|
|
56
|
+
updateText(root, '[data-ev-price-unit]', `${currency.symbol} / kWh`);
|
|
57
|
+
updateText(root, '[data-ev-price-label]', `${ui.priceLabel} (${currency.code})`);
|
|
58
|
+
updateText(root, '[data-ev-currency-factor]', ui.currencyFactorTemplate
|
|
59
|
+
.replace('{factor}', formatFactor(currency.unitsPerEur))
|
|
60
|
+
.replace('{currency}', currency.code));
|
|
61
|
+
root.querySelectorAll<HTMLElement>('[data-ev-currency-option]').forEach((option) => {
|
|
62
|
+
const active = option.dataset.evCurrencyOption === currency.code;
|
|
63
|
+
option.setAttribute('aria-selected', String(active));
|
|
64
|
+
option.classList.toggle('is-active', active);
|
|
65
|
+
});
|
|
66
|
+
root.querySelectorAll<HTMLElement>('[data-ev-preset-meta]').forEach((meta) => {
|
|
67
|
+
const presetId = meta.dataset.evPresetMeta;
|
|
68
|
+
const preset = CHARGING_PRESETS.find((item) => item.id === presetId);
|
|
69
|
+
if (!preset) return;
|
|
70
|
+
const presetCurrency = getCurrencyOption(preset.inputs.currency);
|
|
71
|
+
const price = (preset.inputs.pricePerKwh / presetCurrency.unitsPerEur) * currency.unitsPerEur;
|
|
72
|
+
meta.textContent = ui.presetMetaTemplate
|
|
73
|
+
.replace('{power}', formatPrice(preset.inputs.chargerPowerKw))
|
|
74
|
+
.replace('{symbol}', currency.symbol)
|
|
75
|
+
.replace('{price}', formatPrice(price));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getAddedLevel(index: number, endSegment: number): string {
|
|
80
|
+
return index < endSegment ? 'added' : 'empty';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function updateBattery(root: HTMLElement, startSoc: number, endSoc: number): void {
|
|
84
|
+
const segments = Array.from(root.querySelectorAll<HTMLElement>('[data-ev-segment]'));
|
|
85
|
+
const startSegment = Math.round((startSoc / 100) * segments.length);
|
|
86
|
+
const endSegment = Math.round((endSoc / 100) * segments.length);
|
|
87
|
+
segments.forEach((segment, index) => {
|
|
88
|
+
const level = index < startSegment ? 'held' : getAddedLevel(index, endSegment);
|
|
89
|
+
segment.dataset.level = level;
|
|
90
|
+
});
|
|
91
|
+
root.style.setProperty('--ev-start-soc', `${startSoc}%`);
|
|
92
|
+
root.style.setProperty('--ev-end-soc', `${endSoc}%`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function renderChargingPlan(
|
|
96
|
+
root: HTMLElement,
|
|
97
|
+
view: {
|
|
98
|
+
ui: EVChargingTimeCostPlannerUI;
|
|
99
|
+
inputs: ChargingInputs;
|
|
100
|
+
plan: ChargingPlan;
|
|
101
|
+
status: ChargingStatus;
|
|
102
|
+
},
|
|
103
|
+
): void {
|
|
104
|
+
const { ui, inputs, plan, status } = view;
|
|
105
|
+
updateCurrencyContext(root, ui, inputs);
|
|
106
|
+
updateBattery(root, inputs.startSoc, inputs.endSoc);
|
|
107
|
+
root.dataset.status = status;
|
|
108
|
+
updateText(root, '[data-ev-start-soc]', `${formatNumber(inputs.startSoc)}%`);
|
|
109
|
+
updateText(root, '[data-ev-end-soc]', `${formatNumber(inputs.endSoc)}%`);
|
|
110
|
+
updateText(root, '[data-ev-battery-energy]', `${formatNumber(plan.batteryEnergyKwh)} kWh`);
|
|
111
|
+
updateText(root, '[data-ev-grid-energy]', `${formatNumber(plan.gridEnergyKwh)} kWh`);
|
|
112
|
+
updateText(root, '[data-ev-loss-energy]', `${formatNumber(plan.lossEnergyKwh)} kWh`);
|
|
113
|
+
updateText(root, '[data-ev-time]', formatDuration(plan.timeMinutes));
|
|
114
|
+
updateText(root, '[data-ev-cost]', formatCurrency(plan.cost, inputs.currency));
|
|
115
|
+
updateText(root, '[data-ev-result-range]', `${formatNumber(plan.recoveredRangeKm)} ${ui.rangeUnit}`);
|
|
116
|
+
updateText(root, '[data-ev-status]', statusText(ui, status));
|
|
117
|
+
updateText(root, '[data-ev-power]', `${formatNumber(inputs.chargerPowerKw)} kW`);
|
|
118
|
+
updateText(root, '[data-ev-summary]', ui.summaryTemplate
|
|
119
|
+
.replace('{battery}', formatNumber(plan.batteryEnergyKwh))
|
|
120
|
+
.replace('{range}', formatNumber(plan.recoveredRangeKm))
|
|
121
|
+
.replace('{power}', formatNumber(inputs.chargerPowerKw))
|
|
122
|
+
.replace('{time}', formatDuration(plan.timeMinutes))
|
|
123
|
+
.replace('{cost}', formatCurrency(plan.cost, inputs.currency)));
|
|
124
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MotorToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { EVChargingTimeCostPlannerUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { EVChargingTimeCostPlannerUI };
|
|
5
|
+
export type EVChargingTimeCostPlannerLocaleContent = ToolLocaleContent<EVChargingTimeCostPlannerUI>;
|
|
6
|
+
|
|
7
|
+
export const evChargingTimeCostPlanner: MotorToolEntry<EVChargingTimeCostPlannerUI> = {
|
|
8
|
+
id: 'ev-charging-time-cost-planner',
|
|
9
|
+
icons: {
|
|
10
|
+
bg: 'mdi:ev-station',
|
|
11
|
+
fg: 'mdi:flash-outline',
|
|
12
|
+
},
|
|
13
|
+
i18n: {
|
|
14
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
15
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
16
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
17
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
18
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
19
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
20
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
21
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
22
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
23
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
24
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
25
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
26
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
27
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
28
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
29
|
+
},
|
|
30
|
+
};
|