v0.5.2
This commit is contained in:
+178
-3
@@ -194,6 +194,7 @@ function renderAll() {
|
||||
renderSummary();
|
||||
renderHouseClimate();
|
||||
renderControlPlan();
|
||||
renderSimulationPage();
|
||||
renderDevices();
|
||||
renderZones();
|
||||
renderSchedules();
|
||||
@@ -267,10 +268,179 @@ function renderControlPlan() {
|
||||
}
|
||||
|
||||
async function loadControlPlan() {
|
||||
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); }
|
||||
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); }
|
||||
catch (error) { console.warn('Unable to load control plan:', error); }
|
||||
}
|
||||
|
||||
function simulationTime(value, options = {}) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', '');
|
||||
}
|
||||
|
||||
function simulationNumeric(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function simulationOutdoorAssist(mode, outdoor, room, target) {
|
||||
if (outdoor == null || room == null || target == null) return 0;
|
||||
const roomError = Math.abs(room - target);
|
||||
const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1);
|
||||
return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1);
|
||||
}
|
||||
|
||||
function simulationRoundDeviceSetpoint(mode, demand, value) {
|
||||
const safe = clamp(Number(value) || 0, 16, 30);
|
||||
if (mode === 'heat' && demand) return Math.ceil(safe);
|
||||
if (mode === 'heat' && !demand) return Math.floor(safe);
|
||||
if (mode !== 'heat' && demand) return Math.floor(safe);
|
||||
return Math.ceil(safe);
|
||||
}
|
||||
|
||||
function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
|
||||
if (!demand) return 1;
|
||||
const error = Math.abs(room - target);
|
||||
const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32;
|
||||
if (error >= 2 || extremeWeather) return 3;
|
||||
if (error >= 1) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function simulationStatusInfo(status) {
|
||||
const map = {
|
||||
disabled: {label: tr('common.disabled'), badge: ''},
|
||||
off: {label: tr('mode.off'), badge: ''},
|
||||
waiting: {label: tr('simulation.waitingForData'), badge: ''},
|
||||
demand: {label: tr('simulation.stateDemand'), badge: 'active'},
|
||||
satisfied: {label: tr('simulation.stateSatisfied'), badge: ''},
|
||||
};
|
||||
return map[status] || {label: status, badge: ''};
|
||||
}
|
||||
|
||||
function buildZoneSimulation(zone, planZone) {
|
||||
const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id));
|
||||
const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off';
|
||||
const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature);
|
||||
const target = simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint);
|
||||
const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature);
|
||||
const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1);
|
||||
const half = hysteresis / 2;
|
||||
let status = 'waiting';
|
||||
let demand = false;
|
||||
if (!zone?.enabled || planZone?.enabled === false) status = 'disabled';
|
||||
else if (mode === 'off') status = 'off';
|
||||
else if (current == null || target == null) status = 'waiting';
|
||||
else {
|
||||
if (mode === 'heat') {
|
||||
if (current <= target - half) demand = true;
|
||||
else if (current >= target + half) demand = false;
|
||||
else demand = !!zone?.demand;
|
||||
} else {
|
||||
if (current >= target + half) demand = true;
|
||||
else if (current <= target - half) demand = false;
|
||||
else demand = !!zone?.demand;
|
||||
}
|
||||
status = demand ? 'demand' : 'satisfied';
|
||||
}
|
||||
const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5);
|
||||
const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target);
|
||||
const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist);
|
||||
const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset);
|
||||
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
|
||||
const fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed);
|
||||
const quiet = zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual');
|
||||
const reasoning = !zone?.enabled || planZone?.enabled === false
|
||||
? tr('simulation.reasonDisabled')
|
||||
: mode === 'off'
|
||||
? tr('simulation.reasonHouseOff')
|
||||
: current == null || target == null
|
||||
? tr('simulation.reasonWaiting')
|
||||
: demand
|
||||
? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)})
|
||||
: tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)});
|
||||
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, reasoning};
|
||||
}
|
||||
|
||||
function simulationRuleAction(rule) {
|
||||
const bits = [];
|
||||
if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`);
|
||||
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${modeLabel(rule.action.mode)}`);
|
||||
if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`);
|
||||
return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview');
|
||||
}
|
||||
|
||||
function renderSimulationPage() {
|
||||
const summaryHost = $('#simulationSummary');
|
||||
const timelineHost = $('#simulationTimeline');
|
||||
const zonesHost = $('#simulationZones');
|
||||
const rulesHost = $('#simulationRules');
|
||||
if (!summaryHost || !timelineHost || !zonesHost || !rulesHost) return;
|
||||
const plan = app.controlPlan;
|
||||
if (!plan) {
|
||||
summaryHost.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
|
||||
timelineHost.innerHTML = '';
|
||||
zonesHost.innerHTML = '';
|
||||
rulesHost.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const enabledZones = (plan.zones || []).filter(zone => zone.enabled);
|
||||
const demandingZones = enabledZones.filter(zone => zone.demand);
|
||||
summaryHost.innerHTML = [
|
||||
{label: tr('simulation.houseMode'), value: modeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})},
|
||||
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
|
||||
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: tr('simulation.requestingZones', {count: demandingZones.length})},
|
||||
{label: tr('simulation.rulesLabel'), value: String((plan.rules || []).filter(rule => rule.enabled).length), note: tr('simulation.eventsQueued', {count: (plan.next_events || []).length})},
|
||||
].map(card => `<article class="panel simulation-summary-card"><small>${esc(card.label)}</small><strong>${esc(card.value)}</strong><span>${esc(card.note)}</span></article>`).join('');
|
||||
|
||||
const timelineItems = [];
|
||||
(plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
|
||||
(plan.zones || []).forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
|
||||
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
for (const item of timelineItems) {
|
||||
const key = `${item.scope}|${item.event.at}|${item.event.label}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(item);
|
||||
if (unique.length >= 16) break;
|
||||
}
|
||||
timelineHost.innerHTML = unique.length ? unique.map(item => {
|
||||
const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`;
|
||||
return `<article class="simulation-timeline-item"><time>${esc(simulationTime(item.event.at))}</time><div><strong>${esc(item.scope)}</strong><p>${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}</p></div></article>`;
|
||||
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
|
||||
|
||||
zonesHost.innerHTML = (plan.zones || []).length ? plan.zones.map(planZone => {
|
||||
const zone = app.zones.find(item => item.id === planZone.zone_id);
|
||||
const sim = buildZoneSimulation(zone, planZone);
|
||||
const status = simulationStatusInfo(sim.status);
|
||||
const events = (planZone.next_events || []).slice(0, 4);
|
||||
const sourceLabel = zoneControlSourceLabel(planZone.control_source);
|
||||
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
|
||||
const commandText = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText} · ${tr('devices.quiet')}: ${sim.quiet}`;
|
||||
return `<article class="panel simulation-zone-card ${planZone.enabled ? '' : 'disabled'}">
|
||||
<div class="simulation-zone-head"><div><span class="eyebrow">${esc(planZone.device_name || tr('common.noDevice'))}</span><h3>${esc(planZone.zone_name)}</h3><p>${esc(modeLabel(sim.mode))} · ${esc(zonePresetLabel(planZone.preset))}${planZone.current_schedule_name ? ` · ${esc(planZone.current_schedule_name)}` : ''}</p></div><span class="badge ${status.badge}">${esc(status.label)}</span></div>
|
||||
<div class="simulation-flow">
|
||||
<div class="simulation-step"><small>${esc(tr('simulation.stepRoom'))}</small><strong>${esc(fmtTemp(sim.current))}</strong><span>${esc(sourceLabel)}</span></div>
|
||||
<div class="simulation-step"><small>${esc(tr('simulation.stepTarget'))}</small><strong>${esc(sim.target == null ? '—' : `${sim.target.toFixed(1)}°C`)}</strong><span>${esc(tr('simulation.hysteresis', {value: sim.hysteresis.toFixed(1)}))}</span></div>
|
||||
<div class="simulation-step"><small>${esc(tr('simulation.stepDecision'))}</small><strong>${esc(status.label)}</strong><span>${esc(sim.reasoning)}</span></div>
|
||||
<div class="simulation-step accent"><small>${esc(tr('simulation.stepCommand'))}</small><strong>${esc(commandText)}</strong><span>${esc(sim.demand ? tr('simulation.activeTargetLabel', {value: sim.activeTarget == null ? '—' : sim.activeTarget.toFixed(1)}) : tr('simulation.standbyTargetLabel', {value: sim.standbyTarget == null ? '—' : sim.standbyTarget.toFixed(1)}))}</span></div>
|
||||
</div>
|
||||
<div class="simulation-metrics">
|
||||
<div><small>${esc(tr('simulation.deviceSetpoint'))}</small><strong>${esc(sim.desiredDeviceTarget == null ? '—' : fmtTemp(sim.desiredDeviceTarget))}</strong></div>
|
||||
<div><small>${esc(tr('common.fan'))}</small><strong>${esc(fanText)}</strong></div>
|
||||
<div><small>${esc(tr('devices.quiet'))}</small><strong>${esc(sim.quiet)}</strong></div>
|
||||
<div><small>${esc(tr('simulation.outdoor'))}</small><strong>${esc(sim.outdoor == null ? '—' : fmtTemp(sim.outdoor))}</strong></div>
|
||||
</div>
|
||||
<ul class="plan-events simulation-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul>
|
||||
</article>`;
|
||||
}).join('') : `<div class="empty">${esc(tr('zones.emptyText'))}</div>`;
|
||||
|
||||
const activeRules = (plan.rules || []).filter(rule => rule.enabled);
|
||||
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
|
||||
}
|
||||
|
||||
function scheduleControlPlanLoad() {
|
||||
clearTimeout(app.controlPlanTimer);
|
||||
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
|
||||
@@ -485,7 +655,7 @@ function formatDuration(seconds) {
|
||||
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', settings:'/settings', logs:'/events'};
|
||||
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', simulation:'/simulation', settings:'/settings', logs:'/events'};
|
||||
const HISTORY_TABS = ['overview','zones','devices','sensors','custom'];
|
||||
|
||||
function currentHistoryPath() {
|
||||
@@ -510,7 +680,7 @@ function updateBrowserUrl(path, replace=false) {
|
||||
function showView(name, {push=true, scroll=true}={}) {
|
||||
app.currentView = name;
|
||||
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','settings','logs'].includes(name))));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','simulation','settings','logs'].includes(name))));
|
||||
if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard'));
|
||||
if (scroll) window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
|
||||
@@ -1264,6 +1434,11 @@ $('#haTest').addEventListener('click', async () => {
|
||||
} catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
$('#simulationRefresh')?.addEventListener('click', async () => {
|
||||
try { await loadControlPlan(); toast(tr('common.updated')); }
|
||||
catch (error) { toast(error.message, true); }
|
||||
});
|
||||
|
||||
$('#exportSettings').addEventListener('click', async () => {
|
||||
try {
|
||||
const data = await api('/api/settings/export');
|
||||
|
||||
@@ -91,6 +91,20 @@
|
||||
<div class="list-grid" id="automationList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="simulation">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="simulation.eyebrow">Simulator</span><h1 data-i18n="simulation.title">Automation simulator</h1></div><button class="secondary" id="simulationRefresh" data-i18n="actions.refresh">Refresh</button></div>
|
||||
<p class="lead" data-i18n="simulation.description">Preview how the thermostat and automation logic currently reason about each zone: temperatures, targets, demand, standby behavior and the next scheduled events.</p>
|
||||
<div class="simulation-summary-grid" id="simulationSummary"></div>
|
||||
<div class="panel simulation-timeline-panel">
|
||||
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.timelineEyebrow">Timeline</span><h2 data-i18n="simulation.timelineTitle">What will happen next</h2></div></div>
|
||||
<div class="simulation-timeline" id="simulationTimeline"></div>
|
||||
</div>
|
||||
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.zoneEyebrow">Zones</span><h2 data-i18n="simulation.zoneTitle">Thermostat decision flow</h2></div></div>
|
||||
<div class="simulation-zone-grid" id="simulationZones"></div>
|
||||
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.rulesEyebrow">Rules</span><h2 data-i18n="simulation.rulesTitle">Automation rules</h2></div></div>
|
||||
<div class="simulation-rules-grid" id="simulationRules"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="history">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="history.measurements">Measurements</span><h1 data-i18n="history.title">Climate history</h1></div></div>
|
||||
<div class="history-tabs" id="historyTabs" role="tablist">
|
||||
@@ -204,6 +218,7 @@
|
||||
<div class="menu-list">
|
||||
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span>›</span></button>
|
||||
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span>›</span></button>
|
||||
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span>›</span></button>
|
||||
<button data-go="settings"><span data-i18n="nav.settings">Settings</span><span>›</span></button>
|
||||
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span>›</span></button>
|
||||
</div>
|
||||
|
||||
@@ -461,3 +461,48 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
|
||||
}
|
||||
|
||||
.history-chart-card canvas { width: 100%; min-width: 720px; }
|
||||
|
||||
.simulation-summary-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap:12px; margin-bottom:16px; }
|
||||
.simulation-summary-card { display:grid; gap:6px; padding:16px; }
|
||||
.simulation-summary-card small { color:var(--muted); font-size:12px; }
|
||||
.simulation-summary-card strong { font-size:28px; letter-spacing:-.04em; }
|
||||
.simulation-summary-card span { color:var(--muted); font-size:12px; line-height:1.45; }
|
||||
.simulation-section-head { display:flex; align-items:end; justify-content:space-between; margin:24px 0 12px; }
|
||||
.simulation-section-head h2 { margin:4px 0 0; }
|
||||
.simulation-timeline-panel { padding:16px; margin-bottom:22px; }
|
||||
.simulation-timeline { display:grid; gap:10px; }
|
||||
.simulation-timeline-item { display:grid; grid-template-columns:110px 1fr; gap:12px; align-items:start; padding:12px 0; border-bottom:1px solid var(--line); }
|
||||
.simulation-timeline-item:last-child { border-bottom:0; padding-bottom:0; }
|
||||
.simulation-timeline-item time { color:var(--muted); font-size:12px; }
|
||||
.simulation-timeline-item strong { display:block; margin-bottom:4px; }
|
||||
.simulation-timeline-item p { margin:0; color:var(--muted); line-height:1.45; }
|
||||
.simulation-zone-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap:14px; }
|
||||
.simulation-zone-card { display:grid; gap:14px; padding:16px; }
|
||||
.simulation-zone-card.disabled { opacity:.68; }
|
||||
.simulation-zone-head, .simulation-rule-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.simulation-zone-head h3, .simulation-rule-head h3 { margin:3px 0 0; }
|
||||
.simulation-zone-head p, .simulation-rule-head p { margin:6px 0 0; color:var(--muted); font-size:13px; line-height:1.45; }
|
||||
.simulation-flow { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; }
|
||||
.simulation-step { display:grid; gap:6px; padding:12px; border:1px solid var(--line); border-radius:16px; background:rgba(255,255,255,.02); min-height:112px; }
|
||||
html[data-theme='light'] .simulation-step { background:rgba(0,0,0,.02); }
|
||||
.simulation-step.accent { box-shadow:inset 0 0 0 1px rgba(101,163,255,.28); }
|
||||
.simulation-step small, .simulation-metrics small, .simulation-rule-meta small { color:var(--muted); font-size:11px; }
|
||||
.simulation-step strong { font-size:15px; line-height:1.35; }
|
||||
.simulation-step span { color:var(--muted); font-size:12px; line-height:1.4; }
|
||||
.simulation-metrics { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; }
|
||||
.simulation-metrics > div, .simulation-rule-action { padding:12px; border-radius:14px; border:1px solid var(--line); background:rgba(255,255,255,.02); }
|
||||
html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .simulation-rule-action { background:rgba(0,0,0,.02); }
|
||||
.simulation-metrics strong { display:block; margin-top:4px; }
|
||||
.simulation-events { padding-top:0; border-top:0; }
|
||||
.simulation-rules-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 320px), 1fr)); gap:14px; }
|
||||
.simulation-rule-card { display:grid; gap:12px; padding:16px; }
|
||||
.simulation-rule-card > p { margin:0; color:var(--muted); }
|
||||
.simulation-rule-action { font-size:14px; line-height:1.5; }
|
||||
.simulation-rule-meta { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); }
|
||||
@media (max-width: 900px) {
|
||||
.simulation-flow, .simulation-metrics { grid-template-columns:repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.simulation-timeline-item { grid-template-columns:1fr; gap:6px; }
|
||||
.simulation-flow, .simulation-metrics { grid-template-columns:1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user