a
This commit is contained in:
parent
d163ceb291
commit
f6d7412744
94 changed files with 3836 additions and 1226 deletions
396
js/ui_charts.js
396
js/ui_charts.js
|
|
@ -15,7 +15,7 @@ function bindColonyMoodTooltip() {
|
|||
}
|
||||
|
||||
function colonyChartObjectKeys() {
|
||||
return ["grass", "zunchi", "trace", "grass_bed", "water"];
|
||||
return ["grass", "zunchi", "trace", "grave", "grass_bed", "water"];
|
||||
}
|
||||
|
||||
function colonyStatsUiVisible() {
|
||||
|
|
@ -26,7 +26,15 @@ function colonyStatsUiVisible() {
|
|||
return true;
|
||||
}
|
||||
|
||||
function collectDynamicColonyStats() {
|
||||
function collectDynamicColonyStats(options = {}) {
|
||||
const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||
const cache = world._colonyStatsDynamicCache;
|
||||
const cacheAge = now - (Number(world._colonyStatsDynamicCacheAt) || 0);
|
||||
// Colony cards can ask for a refresh from many UI paths. Reuse one aggregate
|
||||
// for a short real-time window instead of rescanning every Tarinai each time.
|
||||
// Fixed history samples opt into force=true so their recorded values remain
|
||||
// tied to the requested simulation timestamp.
|
||||
if (options.force !== true && cache && cacheAge >= 0 && cacheAge < 800) return cache;
|
||||
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
|
||||
const needSums = Object.fromEntries(needKeys.map(key => [key, 0]));
|
||||
let liveCount = 0;
|
||||
|
|
@ -54,6 +62,7 @@ function collectDynamicColonyStats() {
|
|||
...Object.fromEntries(needKeys.map(key => [`need_${key}`, liveCount ? needSums[key] / divisor : 0])),
|
||||
};
|
||||
world._colonyStatsDynamicCache = dynamic;
|
||||
world._colonyStatsDynamicCacheAt = now;
|
||||
return dynamic;
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +92,13 @@ function renderStats(options = {}) {
|
|||
stress: dynamic.stress || 0,
|
||||
temperature: Number(temp) || 0,
|
||||
ants: dynamic.ants || 0,
|
||||
graves: (() => {
|
||||
const bucket = typeof world.itemsOfType === "function" ? world.itemsOfType("grave") : null;
|
||||
if (bucket && typeof bucket.length === "number") return Math.max(0, bucket.length);
|
||||
let count = 0;
|
||||
for (const item of world.items || []) if (item && !item.dead && item.type === "grave") count += 1;
|
||||
return count;
|
||||
})(),
|
||||
...dynamic,
|
||||
objects: world.itemCounts || {},
|
||||
};
|
||||
|
|
@ -113,126 +129,143 @@ function renderStats(options = {}) {
|
|||
bindColonyMoodTooltip();
|
||||
window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood);
|
||||
window.TarinaiGroundUI.update(world);
|
||||
updateColonyHistory(values);
|
||||
drawColonyChart(values);
|
||||
if (ui?.selectedCard && !ui.selectedCard.classList.contains("hidden")) renderSelected();
|
||||
if (world.familyTreeDirty) scheduleArchiveWindowRender();
|
||||
}
|
||||
|
||||
function updateColonyHistory(values) {
|
||||
const t = Math.max(0, world.time || 0);
|
||||
function updateColonyHistory(values, options = {}) {
|
||||
const t = Math.max(0, Number(world.time || 0) || 0);
|
||||
const history = uiCache.chartHistory;
|
||||
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
|
||||
const maxPoints = 5 * 12;
|
||||
const currentBirthEvents = Math.max(0, Number(values.birthEvents || 0) || 0);
|
||||
const currentDeathEvents = Math.max(0, Number(values.deathEvents || 0) || 0);
|
||||
const eventCountersRewound = (Number.isFinite(uiCache.chartBirthEventSeen) && currentBirthEvents < uiCache.chartBirthEventSeen)
|
||||
|| (Number.isFinite(uiCache.chartDeathEventSeen) && currentDeathEvents < uiCache.chartDeathEventSeen);
|
||||
if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart) || eventCountersRewound) {
|
||||
const maxPoints = 20 * 12;
|
||||
const birthsTotal = Math.max(0, Number(values.birthEvents || 0) || 0);
|
||||
const deathsTotal = Math.max(0, Number(values.deathEvents || 0) || 0);
|
||||
const lastT = history.length ? Number(history[history.length - 1]?.t || 0) : -Infinity;
|
||||
const rewound = t + 1e-6 < lastT
|
||||
|| (Number.isFinite(uiCache.chartNextSampleAt) && t + interval < uiCache.chartNextSampleAt)
|
||||
|| (Number.isFinite(uiCache.chartBirthEventSeen) && birthsTotal < uiCache.chartBirthEventSeen)
|
||||
|| (Number.isFinite(uiCache.chartDeathEventSeen) && deathsTotal < uiCache.chartDeathEventSeen);
|
||||
if (rewound) {
|
||||
history.length = 0;
|
||||
uiCache.chartBucketStart = -Infinity;
|
||||
uiCache.chartBucketEnd = -Infinity;
|
||||
uiCache.chartBucketAccum = null;
|
||||
uiCache.chartNextSampleAt = 0;
|
||||
uiCache.chartBirthEventSeen = null;
|
||||
uiCache.chartDeathEventSeen = null;
|
||||
uiCache.lastChartDraw = "";
|
||||
}
|
||||
if (!Number.isFinite(uiCache.chartNextSampleAt)) uiCache.chartNextSampleAt = 0;
|
||||
if (options.force !== true && t + 1e-6 < uiCache.chartNextSampleAt) return false;
|
||||
if (!Number.isFinite(uiCache.chartBirthEventSeen)) uiCache.chartBirthEventSeen = birthsTotal;
|
||||
if (!Number.isFinite(uiCache.chartDeathEventSeen)) uiCache.chartDeathEventSeen = deathsTotal;
|
||||
const births = Math.max(0, birthsTotal - uiCache.chartBirthEventSeen);
|
||||
const deaths = Math.max(0, deathsTotal - uiCache.chartDeathEventSeen);
|
||||
uiCache.chartBirthEventSeen = birthsTotal;
|
||||
uiCache.chartDeathEventSeen = deathsTotal;
|
||||
const objectKeys = colonyChartObjectKeys();
|
||||
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
|
||||
const makeBucket = (start) => ({
|
||||
start,
|
||||
end: start + interval,
|
||||
count: 0,
|
||||
sums: {
|
||||
pop: 0,
|
||||
zunchiSlaves: 0,
|
||||
tarinaiKings: 0,
|
||||
sick: 0,
|
||||
juvenile: 0,
|
||||
elder: 0,
|
||||
ants: 0,
|
||||
stress: 0,
|
||||
temperature: 0,
|
||||
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
|
||||
},
|
||||
objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
|
||||
events: { births: 0, deaths: 0 },
|
||||
});
|
||||
if (!uiCache.chartBucketAccum) {
|
||||
const start = Math.floor(t / interval) * interval;
|
||||
uiCache.chartBucketStart = start;
|
||||
uiCache.chartBucketEnd = start + interval;
|
||||
uiCache.chartBucketAccum = makeBucket(start);
|
||||
}
|
||||
const pushBucket = (bucket) => {
|
||||
if (!bucket || bucket.count <= 0) return;
|
||||
const row = {
|
||||
t: bucket.end,
|
||||
pop: bucket.sums.pop / bucket.count,
|
||||
zunchiSlaves: bucket.sums.zunchiSlaves / bucket.count,
|
||||
tarinaiKings: bucket.sums.tarinaiKings / bucket.count,
|
||||
sick: bucket.sums.sick / bucket.count,
|
||||
juvenile: bucket.sums.juvenile / bucket.count,
|
||||
elder: bucket.sums.elder / bucket.count,
|
||||
ants: bucket.sums.ants / bucket.count,
|
||||
stress: bucket.sums.stress / bucket.count,
|
||||
temperature: bucket.sums.temperature / bucket.count,
|
||||
...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])),
|
||||
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
|
||||
births: Math.max(0, Number(bucket.events?.births || 0) || 0),
|
||||
deaths: Math.max(0, Number(bucket.events?.deaths || 0) || 0),
|
||||
};
|
||||
history.push(row);
|
||||
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
|
||||
const sampleT = Math.max(0, Math.floor(t / interval) * interval);
|
||||
const row = {
|
||||
t: sampleT,
|
||||
pop: values.pop || 0,
|
||||
zunchiSlaves: values.zunchiSlaves || 0,
|
||||
tarinaiKings: values.tarinaiKings || 0,
|
||||
sick: values.sick || 0,
|
||||
juvenile: values.juvenile || 0,
|
||||
elder: values.elder || 0,
|
||||
ants: values.ants || 0,
|
||||
stress: values.stress || 0,
|
||||
temperature: Number(values.temperature || 0) || 0,
|
||||
...Object.fromEntries(needKeys.map(k => [`need_${k}`, values[`need_${k}`] || 0])),
|
||||
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, k === "grave" ? (values.graves || 0) : ((values.objects || {})[k] || 0)])),
|
||||
births,
|
||||
deaths,
|
||||
};
|
||||
while (t >= uiCache.chartBucketEnd) {
|
||||
const b = uiCache.chartBucketAccum;
|
||||
pushBucket(b);
|
||||
uiCache.chartBucketStart = uiCache.chartBucketEnd;
|
||||
uiCache.chartBucketEnd = uiCache.chartBucketStart + interval;
|
||||
uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart);
|
||||
if (history.length && Math.abs((Number(history[history.length - 1]?.t) || 0) - sampleT) < 1e-6) history[history.length - 1] = row;
|
||||
else history.push(row);
|
||||
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
|
||||
uiCache.chartNextSampleAt = sampleT + interval;
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectColonyHistorySampleValues() {
|
||||
const counts = world.tarinaiCounts?.() || world.rebuildTarinaiCountCache?.("chart-sample") || { alive: 0, zunchiSlaves: 0, tarinaiKings: 0 };
|
||||
const dynamic = collectDynamicColonyStats({ force: true });
|
||||
const graveBucket = typeof world.itemsOfType === "function" ? world.itemsOfType("grave") : null;
|
||||
let graves = 0;
|
||||
if (graveBucket && typeof graveBucket.length === "number") graves = Math.max(0, graveBucket.length);
|
||||
else for (const item of world.items || []) if (item && !item.dead && item.type === "grave") graves += 1;
|
||||
return {
|
||||
pop: Math.max(0, Number(counts.alive || 0) || 0),
|
||||
zunchiSlaves: Math.max(0, Number(counts.zunchiSlaves || 0) || 0),
|
||||
tarinaiKings: Math.max(0, Number(counts.tarinaiKings || 0) || 0),
|
||||
sick: dynamic.sick || 0,
|
||||
juvenile: dynamic.juvenile || 0,
|
||||
elder: dynamic.elder || 0,
|
||||
ants: dynamic.ants || 0,
|
||||
stress: dynamic.stress || 0,
|
||||
temperature: Number(world.currentTemperature ?? world.updateTemperature?.(0) ?? (CONFIG.standardTemperature ?? 15)) || 0,
|
||||
birthEvents: Math.max(0, Number(world.birthEventCount || 0) || 0),
|
||||
deathEvents: Math.max(0, Number(world.deadCount || 0) || 0),
|
||||
graves,
|
||||
...dynamic,
|
||||
objects: world.itemCounts || {},
|
||||
};
|
||||
}
|
||||
|
||||
function tickColonyHistorySampling() {
|
||||
const t = Math.max(0, Number(world?.time || 0) || 0);
|
||||
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
|
||||
if (!Number.isFinite(uiCache.chartNextSampleAt)) uiCache.chartNextSampleAt = 0;
|
||||
if (t + 1e-6 < uiCache.chartNextSampleAt) return false;
|
||||
return updateColonyHistory(collectColonyHistorySampleValues(), { force: true });
|
||||
}
|
||||
|
||||
function chartRangeConfig() {
|
||||
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
|
||||
const range = ["day", "season", "year"].includes(uiCache.chartRange) ? uiCache.chartRange : "season";
|
||||
if (range === "day") return { id: range, duration: dayLength, footer: "\u0032\u6642\u9593\u3054\u3068 / 1\u65e5" };
|
||||
if (range === "year") return { id: range, duration: dayLength * 20, footer: "1\u65e51\u70b9 / 20\u65e5\u9593" };
|
||||
return { id: "season", duration: dayLength * 5, footer: "\u0032\u6642\u9593\u3054\u3068 / 5\u65e5\u9593" };
|
||||
}
|
||||
|
||||
function yearRepresentativeRows(rows, minT, maxT) {
|
||||
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
|
||||
const grouped = new Map();
|
||||
for (const row of rows || []) {
|
||||
const t = Math.max(0, Number(row?.t || 0) || 0);
|
||||
if (t < minT - 1e-6 || t > maxT + 1e-6) continue;
|
||||
const day = Math.floor(t / dayLength);
|
||||
const noon = day * dayLength + dayLength * 0.5;
|
||||
const prev = grouped.get(day);
|
||||
if (!prev || Math.abs(t - noon) < Math.abs((Number(prev.t || 0) || 0) - noon)) grouped.set(day, row);
|
||||
}
|
||||
const bucket = uiCache.chartBucketAccum;
|
||||
if (!Number.isFinite(uiCache.chartBirthEventSeen)) uiCache.chartBirthEventSeen = currentBirthEvents;
|
||||
if (!Number.isFinite(uiCache.chartDeathEventSeen)) uiCache.chartDeathEventSeen = currentDeathEvents;
|
||||
bucket.events.births += Math.max(0, currentBirthEvents - uiCache.chartBirthEventSeen);
|
||||
bucket.events.deaths += Math.max(0, currentDeathEvents - uiCache.chartDeathEventSeen);
|
||||
uiCache.chartBirthEventSeen = currentBirthEvents;
|
||||
uiCache.chartDeathEventSeen = currentDeathEvents;
|
||||
bucket.count += 1;
|
||||
bucket.sums.pop += values.pop || 0;
|
||||
bucket.sums.zunchiSlaves += values.zunchiSlaves || 0;
|
||||
bucket.sums.tarinaiKings += values.tarinaiKings || 0;
|
||||
bucket.sums.sick += values.sick || 0;
|
||||
bucket.sums.juvenile += values.juvenile || 0;
|
||||
bucket.sums.elder += values.elder || 0;
|
||||
bucket.sums.ants += values.ants || 0;
|
||||
bucket.sums.stress += values.stress || 0;
|
||||
bucket.sums.temperature += Number(values.temperature || 0) || 0;
|
||||
for (const k of needKeys) bucket.sums[`need_${k}`] += values[`need_${k}`] || 0;
|
||||
for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0;
|
||||
return Array.from(grouped.entries()).sort((a, b) => a[0] - b[0]).map(([, row]) => row);
|
||||
}
|
||||
|
||||
function chartRows(values) {
|
||||
// The colony chart is sampled in two-hour buckets. Do not append a live
|
||||
// preview row here; it makes the graph redraw every frame even though the
|
||||
// recorded statistics have not advanced yet.
|
||||
if (uiCache.chartHistory.length) return uiCache.chartHistory.slice();
|
||||
const allRows = uiCache.chartHistory.length ? uiCache.chartHistory.slice() : [];
|
||||
const now = Math.max(0, Number(world.time || 0) || 0);
|
||||
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
|
||||
// Keep the visible time window anchored to the newest recorded sample rather
|
||||
// than the continuously advancing world clock. The graph now shifts left at
|
||||
// exactly the same moment a new data point is appended.
|
||||
const maxT = allRows.length
|
||||
? Math.max(0, Number(allRows[allRows.length - 1]?.t || 0) || 0)
|
||||
: Math.max(0, Math.floor(now / interval) * interval);
|
||||
const config = chartRangeConfig();
|
||||
const minT = Math.max(0, maxT - config.duration);
|
||||
let rows = allRows.filter(row => {
|
||||
const t = Math.max(0, Number(row?.t || 0) || 0);
|
||||
return t >= minT - 1e-6 && t <= maxT + 1e-6;
|
||||
});
|
||||
if (config.id === "year") rows = yearRepresentativeRows(rows, minT, maxT);
|
||||
if (rows.length) return rows;
|
||||
const objectKeys = colonyChartObjectKeys();
|
||||
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
|
||||
return [{
|
||||
t: Math.max(0, Number(uiCache.chartBucketStart || 0) || 0),
|
||||
pop: 0,
|
||||
zunchiSlaves: 0,
|
||||
tarinaiKings: 0,
|
||||
sick: 0,
|
||||
juvenile: 0,
|
||||
elder: 0,
|
||||
ants: 0,
|
||||
stress: 0,
|
||||
temperature: 0,
|
||||
births: 0,
|
||||
deaths: 0,
|
||||
t: now,
|
||||
pop: 0, zunchiSlaves: 0, tarinaiKings: 0, sick: 0, juvenile: 0, elder: 0, ants: 0,
|
||||
stress: 0, temperature: 0, births: 0, deaths: 0,
|
||||
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
|
||||
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, 0])),
|
||||
}];
|
||||
|
|
@ -247,6 +280,119 @@ function chartSeasonDayLabelForTime(t = 0) {
|
|||
return `${season}${seasonDay}\u65e5`;
|
||||
}
|
||||
|
||||
function chartDateTimeLabelForTime(t = 0) {
|
||||
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
|
||||
const safe = Math.max(0, Number(t || 0) || 0);
|
||||
const dayStart = Math.floor(safe / dayLength) * dayLength;
|
||||
const dayRatio = Math.max(0, Math.min(0.999999, (safe - dayStart) / dayLength));
|
||||
const totalMinutes = Math.floor(dayRatio * 24 * 60);
|
||||
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
|
||||
const mm = String(totalMinutes % 60).padStart(2, "0");
|
||||
return `${chartSeasonDayLabelForTime(safe)} ${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function bindColonyChartHover() {
|
||||
const chart = ui?.colonyChart;
|
||||
if (!chart || chart.dataset.historyHoverBound === "1") return;
|
||||
chart.dataset.historyHoverBound = "1";
|
||||
let tooltip = document.getElementById("colonyChartFloatingTooltip");
|
||||
if (!tooltip) {
|
||||
tooltip = document.createElement("div");
|
||||
tooltip.id = "colonyChartFloatingTooltip";
|
||||
tooltip.className = "colony-chart-tooltip hidden";
|
||||
document.body.appendChild(tooltip);
|
||||
}
|
||||
const hoverMarker = () => {
|
||||
let marker = chart.querySelector(".colony-chart-hover-point");
|
||||
if (!marker) {
|
||||
marker = document.createElement("div");
|
||||
marker.className = "colony-chart-hover-point hidden";
|
||||
chart.appendChild(marker);
|
||||
}
|
||||
return marker;
|
||||
};
|
||||
const hide = () => {
|
||||
tooltip.classList.add("hidden");
|
||||
hoverMarker().classList.add("hidden");
|
||||
};
|
||||
chart.addEventListener("mouseleave", hide);
|
||||
chart.addEventListener("mousemove", (event) => {
|
||||
const state = uiCache.chartHoverState;
|
||||
if (!state?.rows?.length) return hide();
|
||||
const rect = chart.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return hide();
|
||||
const px = Math.max(0, Math.min(state.cssW, (event.clientX - rect.left) * state.cssW / rect.width));
|
||||
const py = Math.max(0, Math.min(state.cssH, (event.clientY - rect.top) * state.cssH / rect.height));
|
||||
const plotX = Math.max(state.pad.l, Math.min(state.pad.l + state.w, px));
|
||||
const ratio = (plotX - state.pad.l) / Math.max(1, state.w);
|
||||
const targetT = state.minT + ratio * Math.max(1, state.maxT - state.minT);
|
||||
let nearest = state.rows[0];
|
||||
let best = Infinity;
|
||||
for (const row of state.rows) {
|
||||
const d = Math.abs((Number(row?.t || 0) || 0) - targetT);
|
||||
if (d < best) { best = d; nearest = row; }
|
||||
}
|
||||
if (!nearest) return hide();
|
||||
|
||||
const snappedX = state.pad.l + clamp(((Number(nearest.t || 0) || 0) - state.minT) / Math.max(1, state.maxT - state.minT), 0, 1) * state.w;
|
||||
const scale = state.scale || { min: 0, max: 100, percent: false };
|
||||
const yForSeries = (seriesDef) => {
|
||||
const raw = Number(nearest?.[seriesDef.key] || 0);
|
||||
if (scale.percent) return state.pad.t + state.h - state.h * clamp(raw / 100, 0, 1);
|
||||
const denom = Math.max(1, scale.max - scale.min);
|
||||
return state.pad.t + state.h - state.h * clamp((raw - scale.min) / denom, 0, 1);
|
||||
};
|
||||
let snapSeries = state.series[0] || null;
|
||||
let snappedY = snapSeries ? yForSeries(snapSeries) : state.pad.t + state.h * 0.5;
|
||||
let bestY = Math.abs(snappedY - py);
|
||||
for (const seriesDef of state.series.slice(1)) {
|
||||
const candidateY = yForSeries(seriesDef);
|
||||
const distance = Math.abs(candidateY - py);
|
||||
if (distance < bestY) {
|
||||
bestY = distance;
|
||||
snapSeries = seriesDef;
|
||||
snappedY = candidateY;
|
||||
}
|
||||
}
|
||||
|
||||
const marker = hoverMarker();
|
||||
marker.style.left = `${snappedX * rect.width / state.cssW}px`;
|
||||
marker.style.top = `${snappedY * rect.height / state.cssH}px`;
|
||||
if (snapSeries?.color) marker.style.borderColor = snapSeries.color;
|
||||
marker.classList.remove("hidden");
|
||||
|
||||
const lines = state.series.map(series => `<div class="colony-chart-tooltip-row"><span>${series.label}</span><b>${formatChartValue(series.key, nearest)}</b></div>`).join("");
|
||||
tooltip.innerHTML = `<strong>${chartDateTimeLabelForTime(nearest.t)}</strong>${lines}`;
|
||||
tooltip.classList.remove("hidden");
|
||||
tooltip.style.visibility = "hidden";
|
||||
tooltip.style.left = "0px";
|
||||
tooltip.style.top = "0px";
|
||||
|
||||
const tipW = Math.max(1, tooltip.offsetWidth || 160);
|
||||
const tipH = Math.max(1, tooltip.offsetHeight || 80);
|
||||
const viewportW = Math.max(1, window.innerWidth || document.documentElement.clientWidth || rect.right + tipW);
|
||||
const viewportH = Math.max(1, window.innerHeight || document.documentElement.clientHeight || rect.bottom + tipH);
|
||||
const gap = 10;
|
||||
const anchorX = rect.left + snappedX * rect.width / state.cssW;
|
||||
const anchorY = rect.top + snappedY * rect.height / state.cssH;
|
||||
const placements = [
|
||||
{ side: "right", left: rect.right + gap, top: anchorY - tipH / 2, fits: viewportW - rect.right >= tipW + gap },
|
||||
{ side: "left", left: rect.left - tipW - gap, top: anchorY - tipH / 2, fits: rect.left >= tipW + gap },
|
||||
{ side: "top", left: anchorX - tipW / 2, top: rect.top - tipH - gap, fits: rect.top >= tipH + gap },
|
||||
{ side: "bottom", left: anchorX - tipW / 2, top: rect.bottom + gap, fits: viewportH - rect.bottom >= tipH + gap },
|
||||
];
|
||||
const placement = placements.find(item => item.fits) || placements[0];
|
||||
const left = Math.max(gap, Math.min(viewportW - tipW - gap, placement.left));
|
||||
const top = Math.max(gap, Math.min(viewportH - tipH - gap, placement.top));
|
||||
tooltip.dataset.side = placement.side;
|
||||
tooltip.style.left = `${Math.round(left)}px`;
|
||||
tooltip.style.top = `${Math.round(top)}px`;
|
||||
tooltip.style.visibility = "visible";
|
||||
});
|
||||
}
|
||||
|
||||
window.bindColonyChartHover = bindColonyChartHover;
|
||||
|
||||
function niceChartMax(raw = 0) {
|
||||
const value = Math.max(1, Number(raw) || 0);
|
||||
const pow = Math.pow(10, Math.floor(Math.log10(value)));
|
||||
|
|
@ -278,12 +424,14 @@ function chartAxisLabel(value, scale) {
|
|||
function drawColonyChart(values) {
|
||||
const chart = ui.colonyChart;
|
||||
if (!chart) return;
|
||||
bindColonyChartHover();
|
||||
let mode = uiCache.chartMode || "population";
|
||||
if (!["population", "life", "objects"].includes(mode)) {
|
||||
mode = "population";
|
||||
uiCache.chartMode = "population";
|
||||
for (const b of ui.colonyChartTabs?.querySelectorAll("button[data-chart]") || []) b.classList.toggle("active", b.dataset.chart === "population");
|
||||
}
|
||||
const rangeConfig = chartRangeConfig();
|
||||
const rows = chartRows(values);
|
||||
const last = rows[rows.length - 1] || values;
|
||||
const allSeries = chartSeries(mode);
|
||||
|
|
@ -291,8 +439,10 @@ function drawColonyChart(values) {
|
|||
const series = allSeries.filter(s => !hiddenSeries.has(s.key));
|
||||
const seriesSnapshot = allSeries.map(s => `${s.key}:${hiddenSeries.has(s.key) ? 0 : 1}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
|
||||
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
|
||||
const visibleDay = Math.floor(Math.max(0, Number(world.time || 0) || 0) / dayLength);
|
||||
const snapshot = `${mode}:${rows.length}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
|
||||
const maxT = Math.max(0, Number(last?.t ?? world.time ?? 0) || 0);
|
||||
const visibleDay = Math.floor(maxT / dayLength);
|
||||
const lastRowT = Math.round(maxT * 10);
|
||||
const snapshot = `${mode}:${rangeConfig.id}:${rows.length}:${lastRowT}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
|
||||
if (uiCache.lastChartDraw === snapshot) return;
|
||||
uiCache.lastChartDraw = snapshot;
|
||||
const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320));
|
||||
|
|
@ -301,20 +451,15 @@ function drawColonyChart(values) {
|
|||
const w = cssW - pad.l - pad.r;
|
||||
const h = cssH - pad.t - pad.b;
|
||||
const scale = chartScaleForRows(mode, rows, series);
|
||||
const times = rows.map(r => Math.max(0, Number(r.t || 0) || 0));
|
||||
const minT = Math.min(...times, Math.max(0, Number(world.time || 0) || 0));
|
||||
const maxT = Math.max(...times, Math.max(0, Number(world.time || 0) || 0), minT + 1);
|
||||
const xForTime = (t) => pad.l + clamp((Number(t || 0) - minT) / Math.max(1, maxT - minT), 0, 1) * w;
|
||||
const xFor = (i) => xForTime(rows[i]?.t ?? maxT);
|
||||
const yFor = (row, s) => {
|
||||
const raw = Number(row[s.key] || 0);
|
||||
if (scale.percent) {
|
||||
const v = clamp(raw / 100, 0, 1);
|
||||
return pad.t + h - h * v;
|
||||
}
|
||||
const chartMaxT = Math.max(1, maxT);
|
||||
const minT = Math.max(0, chartMaxT - rangeConfig.duration);
|
||||
const xForTime = (t) => pad.l + clamp((Number(t || 0) - minT) / Math.max(1, chartMaxT - minT), 0, 1) * w;
|
||||
const xFor = (i) => xForTime(rows[i]?.t ?? chartMaxT);
|
||||
const yFor = (row, seriesDef) => {
|
||||
const raw = Number(row[seriesDef.key] || 0);
|
||||
if (scale.percent) return pad.t + h - h * clamp(raw / 100, 0, 1);
|
||||
const denom = Math.max(1, scale.max - scale.min);
|
||||
const v = clamp((raw - scale.min) / denom, 0, 1);
|
||||
return pad.t + h - h * v;
|
||||
return pad.t + h - h * clamp((raw - scale.min) / denom, 0, 1);
|
||||
};
|
||||
const grid = [0, 25, 50, 75, 100].map(yv => {
|
||||
const y = pad.t + h - h * yv / 100;
|
||||
|
|
@ -322,23 +467,25 @@ function drawColonyChart(values) {
|
|||
return `<line x1="${pad.l}" y1="${y.toFixed(1)}" x2="${(pad.l + w).toFixed(1)}" y2="${y.toFixed(1)}" stroke="rgba(84,68,48,0.13)" stroke-width="1"/><text x="${pad.l - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(117,106,94,0.78)">${chartAxisLabel(rawLabel, scale)}</text>`;
|
||||
}).join("");
|
||||
const firstDay = Math.ceil(minT / dayLength);
|
||||
const lastDay = Math.floor(maxT / dayLength);
|
||||
const lastDay = Math.floor(chartMaxT / dayLength);
|
||||
const dayMarkers = [];
|
||||
for (let d = firstDay; d <= lastDay; d += 1) {
|
||||
const t = d * dayLength;
|
||||
if (t < minT - 0.001 || t > maxT + 0.001) continue;
|
||||
if (t < minT - 0.001 || t > chartMaxT + 0.001) continue;
|
||||
const x = xForTime(t);
|
||||
const label = chartSeasonDayLabelForTime(t);
|
||||
dayMarkers.push(`<line x1="${x.toFixed(1)}" y1="${pad.t}" x2="${x.toFixed(1)}" y2="${(pad.t + h).toFixed(1)}" stroke="rgba(104,104,104,0.38)" stroke-width="1" stroke-dasharray="4 5"/><text x="${(x + 3).toFixed(1)}" y="${(pad.t + 11).toFixed(1)}" font-size="10" fill="rgba(92,92,92,0.76)">${label}</text>`);
|
||||
const showLabel = rangeConfig.id !== "year" || ((d - firstDay) % 2 === 0);
|
||||
dayMarkers.push(`<line x1="${x.toFixed(1)}" y1="${pad.t}" x2="${x.toFixed(1)}" y2="${(pad.t + h).toFixed(1)}" stroke="rgba(104,104,104,0.38)" stroke-width="1" stroke-dasharray="4 5"/>${showLabel ? `<text x="${(x + 3).toFixed(1)}" y="${(pad.t + 11).toFixed(1)}" font-size="10" fill="rgba(92,92,92,0.76)">${label}</text>` : ""}`);
|
||||
}
|
||||
const lines = series.map(s => {
|
||||
const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" ");
|
||||
const last = rows[rows.length - 1];
|
||||
const lines = series.map(seriesDef => {
|
||||
const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, seriesDef).toFixed(1)}`).join(" ");
|
||||
const latest = rows[rows.length - 1];
|
||||
const cx = xFor(rows.length - 1).toFixed(1);
|
||||
const cy = yFor(last, s).toFixed(1);
|
||||
return `<polyline points="${pts}" fill="none" stroke="${s.color}" stroke-width="${s.key === "pop" ? 2.4 : 1.8}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${cx}" cy="${cy}" r="2.7" fill="${s.color}"/>`;
|
||||
const cy = yFor(latest, seriesDef).toFixed(1);
|
||||
return `<polyline points="${pts}" fill="none" stroke="${seriesDef.color}" stroke-width="${seriesDef.key === "pop" ? 2.4 : 1.8}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${cx}" cy="${cy}" r="2.7" fill="${seriesDef.color}"/>`;
|
||||
}).join("");
|
||||
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${grid}${dayMarkers.join("")}${lines}<text x="${pad.l}" y="${cssH - 8}" font-size="10" fill="rgba(117,106,94,0.76)">2\u6642\u9593\u3054\u3068 / 1\u9031\u9593</text></svg>`;
|
||||
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${grid}${dayMarkers.join("")}${lines}<text x="${pad.l}" y="${cssH - 8}" font-size="10" fill="rgba(117,106,94,0.76)">${rangeConfig.footer}</text></svg>`;
|
||||
uiCache.chartHoverState = { rows, series, minT, maxT: chartMaxT, pad, w, h, cssW, cssH, mode, range: rangeConfig.id, scale };
|
||||
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = allSeries.map(s => {
|
||||
const hidden = hiddenSeries.has(s.key);
|
||||
return `<button type="button" class="chart-series-toggle ${hidden ? "series-hidden" : ""}" data-series-key="${s.key}" aria-pressed="${hidden ? "false" : "true"}" title="${hidden ? "\u8868\u793a\u3059\u308b" : "\u975e\u8868\u793a\u306b\u3059\u308b"}"><i aria-hidden="true" style="background:${s.color}"></i><span>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span></button>`;
|
||||
|
|
@ -359,6 +506,7 @@ function chartSeries(mode = "population") {
|
|||
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
|
||||
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f5a2f" },
|
||||
{ key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" },
|
||||
{ key: "obj_grave", label: "\u5893", color: "#77716a" },
|
||||
{ key: "obj_grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", color: "#78ff45" },
|
||||
{ key: "obj_water", label: "\u96e8\u6ef4", color: "#5aa8d6" },
|
||||
{ key: "temperature", label: "\u6c17\u6e29", color: "#c46f3c" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue