improve heatmap ui

This commit is contained in:
pa
2026-03-22 20:50:28 +09:00
parent 03ebf7ea27
commit 4a8418a0b3
4 changed files with 105 additions and 49 deletions
@@ -517,7 +517,7 @@
isDarkMode: isDarkMode.value, isDarkMode: isDarkMode.value,
emptyColor: isDarkMode.value ? 'hsl(220, 15%, 12%)' : 'hsl(210, 30%, 95%)', emptyColor: isDarkMode.value ? 'hsl(220, 15%, 12%)' : 'hsl(210, 30%, 95%)',
scaleColors: isDarkMode.value scaleColors: isDarkMode.value
? ['hsl(160, 35%, 18%)', 'hsl(150, 45%, 25%)', 'hsl(142, 55%, 32%)', 'hsl(142, 65%, 42%)', 'hsl(142, 80%, 55%)'] ? ['hsl(160, 40%, 24%)', 'hsl(150, 48%, 32%)', 'hsl(142, 55%, 38%)', 'hsl(142, 65%, 46%)', 'hsl(142, 80%, 55%)']
: ['hsl(160, 40%, 82%)', 'hsl(155, 45%, 68%)', 'hsl(142, 55%, 55%)', 'hsl(142, 65%, 40%)', 'hsl(142, 76%, 30%)'], : ['hsl(160, 40%, 82%)', 'hsl(155, 45%, 68%)', 'hsl(142, 55%, 55%)', 'hsl(142, 65%, 40%)', 'hsl(142, 76%, 30%)'],
unitLabel: t('dialog.user.activity.minutes_online') unitLabel: t('dialog.user.activity.minutes_online')
}), { notMerge: true }); }), { notMerge: true });
@@ -540,7 +540,7 @@
isDarkMode: isDarkMode.value, isDarkMode: isDarkMode.value,
emptyColor: isDarkMode.value ? 'hsl(220, 15%, 12%)' : 'hsl(210, 30%, 95%)', emptyColor: isDarkMode.value ? 'hsl(220, 15%, 12%)' : 'hsl(210, 30%, 95%)',
scaleColors: isDarkMode.value scaleColors: isDarkMode.value
? ['hsl(260, 25%, 20%)', 'hsl(260, 40%, 30%)', 'hsl(260, 50%, 42%)', 'hsl(260, 60%, 52%)', 'hsl(260, 70%, 62%)'] ? ['hsl(260, 30%, 26%)', 'hsl(260, 42%, 36%)', 'hsl(260, 50%, 45%)', 'hsl(260, 60%, 54%)', 'hsl(260, 70%, 62%)']
: ['hsl(260, 35%, 85%)', 'hsl(260, 42%, 70%)', 'hsl(260, 48%, 58%)', 'hsl(260, 55%, 48%)', 'hsl(260, 60%, 38%)'], : ['hsl(260, 35%, 85%)', 'hsl(260, 42%, 70%)', 'hsl(260, 48%, 58%)', 'hsl(260, 55%, 48%)', 'hsl(260, 60%, 38%)'],
unitLabel: t('dialog.user.activity.overlap.minutes_overlap') unitLabel: t('dialog.user.activity.overlap.minutes_overlap')
}), { notMerge: true }); }), { notMerge: true });
+77 -26
View File
@@ -152,22 +152,64 @@ export function buildOverlapBuckets(
return buildHeatmapBuckets(intersections, windowStartMs, nowMs, maxSessionMs); return buildHeatmapBuckets(intersections, windowStartMs, nowMs, maxSessionMs);
} }
export function normalizeBuckets(buckets, thresholdMinutes, capPercentile, mode) { export function normalizeBuckets(buckets, config) {
const thresholded = buckets.map((value) => (value >= thresholdMinutes ? value : 0)); const {
const positiveValues = thresholded.filter((value) => value > 0).sort((a, b) => a - b); floorPercentile = 15,
const cap = positiveValues.length > 0 ? percentile(positiveValues, capPercentile) : 1; capPercentile = 85,
const normalized = new Float64Array(168); rankWeight = 0.2,
targetCoverage = 0.25,
targetVolume = 60,
rangeDays = 30
} = config;
for (let index = 0; index < 168; index++) { const positiveEntries = [];
const value = thresholded[index]; for (let i = 0; i < 168; i++) {
if (value <= 0) { if (buckets[i] > 0) {
normalized[index] = 0; positiveEntries.push({ value: buckets[i], index: i });
continue;
} }
const scaled = mode === 'log' }
? Math.log1p(value) / Math.log1p(cap)
: Math.sqrt(value / cap); if (positiveEntries.length === 0) {
normalized[index] = Math.min(Math.max(scaled, 0), 1); return Array.from({ length: 168 }, () => 0);
}
const sortedValues = positiveEntries.map((e) => e.value).sort((a, b) => a - b);
const floor = percentile(sortedValues, floorPercentile);
const cap = percentile(sortedValues, capPercentile);
const logFloor = Math.log1p(floor);
const logCap = Math.log1p(cap);
const logRange = logCap - logFloor;
const gated = positiveEntries.filter((e) => e.value >= floor);
if (gated.length === 0) {
return Array.from({ length: 168 }, () => 0);
}
gated.sort((a, b) => a.value - b.value);
const count = gated.length;
const ampWeight = 1 - rankWeight;
const normalized = new Float64Array(168);
const tiedRanks = computeTiedRankScores(gated);
for (let rank = 0; rank < count; rank++) {
const { value, index } = gated[rank];
const base = logRange > 1e-9
? Math.max((Math.log1p(value) - logFloor) / logRange, 0)
: 0.5;
const clampedBase = Math.min(base, 1);
normalized[index] = clampedBase * ampWeight + tiedRanks[rank] * rankWeight;
}
const coverage = count / 168;
const gatedMinutes = gated.reduce((sum, e) => sum + e.value, 0);
const volume = gatedMinutes / rangeDays;
const confidence = Math.min(
Math.max(Math.min(coverage / targetCoverage, volume / targetVolume), 0),
1
);
for (let i = 0; i < 168; i++) {
normalized[i] = Math.min(normalized[i] * confidence, 1);
} }
return Array.from(normalized); return Array.from(normalized);
@@ -255,12 +297,7 @@ export function computeActivityView({
const windowStartMs = nowMs - rangeDays * 86400000; const windowStartMs = nowMs - rangeDays * 86400000;
const clippedSessions = clipSessionsToRange(sessions, windowStartMs, nowMs); const clippedSessions = clipSessionsToRange(sessions, windowStartMs, nowMs);
const rawBuckets = buildHeatmapBuckets(clippedSessions, windowStartMs, nowMs, maxSessionMs); const rawBuckets = buildHeatmapBuckets(clippedSessions, windowStartMs, nowMs, maxSessionMs);
const normalizedBuckets = normalizeBuckets( const normalizedBuckets = normalizeBuckets(rawBuckets, { ...normalizeConfig, rangeDays });
rawBuckets,
normalizeConfig.thresholdMinutes,
normalizeConfig.capPercentile,
normalizeConfig.mode
);
const { peakDay, peakTime } = computePeaksFromBuckets(rawBuckets, dayLabels); const { peakDay, peakTime } = computePeaksFromBuckets(rawBuckets, dayLabels);
return { return {
rangeDays, rangeDays,
@@ -298,12 +335,7 @@ export function computeOverlapView({
const targetMinutes = sum(targetBuckets); const targetMinutes = sum(targetBuckets);
const denominator = Math.min(selfMinutes, targetMinutes); const denominator = Math.min(selfMinutes, targetMinutes);
const overlapPercent = denominator > 0 ? Math.round((overlapMinutes / denominator) * 100) : 0; const overlapPercent = denominator > 0 ? Math.round((overlapMinutes / denominator) * 100) : 0;
const normalizedBuckets = normalizeBuckets( const normalizedBuckets = normalizeBuckets(rawBuckets, { ...normalizeConfig, rangeDays });
rawBuckets,
normalizeConfig.thresholdMinutes,
normalizeConfig.capPercentile,
normalizeConfig.mode
);
const bestOverlapTime = overlapMinutes > 0 const bestOverlapTime = overlapMinutes > 0
? findBestOverlapTimeFromBuckets(rawBuckets, dayLabels) ? findBestOverlapTimeFromBuckets(rawBuckets, dayLabels)
: ''; : '';
@@ -371,3 +403,22 @@ function zeroHourRange(day, startHour, endHour, rawBuckets, selfBuckets, targetB
function sum(values) { function sum(values) {
return values.reduce((total, value) => total + value, 0); return values.reduce((total, value) => total + value, 0);
} }
function computeTiedRankScores(sortedEntries) {
const count = sortedEntries.length;
const scores = new Float64Array(count);
let i = 0;
while (i < count) {
let j = i;
while (j < count && sortedEntries[j].value === sortedEntries[i].value) {
j++;
}
const avgRank = (i + 1 + j) / 2;
const score = avgRank / count;
for (let k = i; k < j; k++) {
scores[k] = score;
}
i = j;
}
return scores;
}
+22 -18
View File
@@ -571,33 +571,37 @@ function pickActivityNormalizeConfig(isSelf, rangeDays) {
const role = isSelf ? 'self' : 'friend'; const role = isSelf ? 'self' : 'friend';
return { return {
self: { self: {
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' }, 7: { floorPercentile: 10, capPercentile: 80, rankWeight: 0.15, targetCoverage: 0.12, targetVolume: 40 },
30: { thresholdMinutes: 10, capPercentile: 95, mode: 'sqrt' }, 30: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.25, targetVolume: 60 },
90: { thresholdMinutes: 20, capPercentile: 90, mode: 'log' }, 90: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.30, targetVolume: 50 },
180: { thresholdMinutes: 30, capPercentile: 85, mode: 'log' } 180: { floorPercentile: 20, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.35, targetVolume: 40 }
}, },
friend: { friend: {
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' }, 7: { floorPercentile: 10, capPercentile: 80, rankWeight: 0.15, targetCoverage: 0.12, targetVolume: 40 },
30: { thresholdMinutes: 10, capPercentile: 95, mode: 'sqrt' }, 30: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.25, targetVolume: 60 },
90: { thresholdMinutes: 20, capPercentile: 90, mode: 'log' }, 90: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.30, targetVolume: 50 },
180: { thresholdMinutes: 30, capPercentile: 85, mode: 'log' } 180: { floorPercentile: 20, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.35, targetVolume: 40 }
} }
}[role][rangeDays] || { }[role][rangeDays] || {
thresholdMinutes: 10, floorPercentile: 15,
capPercentile: 95, capPercentile: 85,
mode: 'sqrt' rankWeight: 0.20,
targetCoverage: 0.25,
targetVolume: 60
}; };
} }
function pickOverlapNormalizeConfig(rangeDays) { function pickOverlapNormalizeConfig(rangeDays) {
return { return {
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' }, 7: { floorPercentile: 10, capPercentile: 80, rankWeight: 0.15, targetCoverage: 0.08, targetVolume: 15 },
30: { thresholdMinutes: 5, capPercentile: 95, mode: 'sqrt' }, 30: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.15, targetVolume: 25 },
90: { thresholdMinutes: 10, capPercentile: 90, mode: 'log' }, 90: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.18, targetVolume: 20 },
180: { thresholdMinutes: 15, capPercentile: 85, mode: 'log' } 180: { floorPercentile: 20, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.20, targetVolume: 15 }
}[rangeDays] || { }[rangeDays] || {
thresholdMinutes: 5, floorPercentile: 15,
capPercentile: 95, capPercentile: 85,
mode: 'sqrt' rankWeight: 0.20,
targetCoverage: 0.15,
targetVolume: 25
}; };
} }
+4 -3
View File
@@ -57,12 +57,13 @@ self.addEventListener('message', (event) => {
}; };
break; break;
case 'normalizeHeatmapBuckets': case 'normalizeHeatmapBuckets':
if ('thresholdMinutes' in payload || 'mode' in payload) {
console.warn('[activityWorker] normalizeHeatmapBuckets received legacy payload fields (thresholdMinutes/mode). Use payload.config instead.');
}
result = { result = {
normalized: normalizeBuckets( normalized: normalizeBuckets(
payload.buckets || [], payload.buckets || [],
payload.thresholdMinutes, payload.config || {}
payload.capPercentile,
payload.mode
) )
}; };
break; break;