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

View File

@@ -517,7 +517,7 @@
isDarkMode: isDarkMode.value,
emptyColor: isDarkMode.value ? 'hsl(220, 15%, 12%)' : 'hsl(210, 30%, 95%)',
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%)'],
unitLabel: t('dialog.user.activity.minutes_online')
}), { notMerge: true });
@@ -540,7 +540,7 @@
isDarkMode: isDarkMode.value,
emptyColor: isDarkMode.value ? 'hsl(220, 15%, 12%)' : 'hsl(210, 30%, 95%)',
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%)'],
unitLabel: t('dialog.user.activity.overlap.minutes_overlap')
}), { notMerge: true });

View File

@@ -152,22 +152,64 @@ export function buildOverlapBuckets(
return buildHeatmapBuckets(intersections, windowStartMs, nowMs, maxSessionMs);
}
export function normalizeBuckets(buckets, thresholdMinutes, capPercentile, mode) {
const thresholded = buckets.map((value) => (value >= thresholdMinutes ? value : 0));
const positiveValues = thresholded.filter((value) => value > 0).sort((a, b) => a - b);
const cap = positiveValues.length > 0 ? percentile(positiveValues, capPercentile) : 1;
const normalized = new Float64Array(168);
export function normalizeBuckets(buckets, config) {
const {
floorPercentile = 15,
capPercentile = 85,
rankWeight = 0.2,
targetCoverage = 0.25,
targetVolume = 60,
rangeDays = 30
} = config;
for (let index = 0; index < 168; index++) {
const value = thresholded[index];
if (value <= 0) {
normalized[index] = 0;
continue;
const positiveEntries = [];
for (let i = 0; i < 168; i++) {
if (buckets[i] > 0) {
positiveEntries.push({ value: buckets[i], index: i });
}
const scaled = mode === 'log'
? Math.log1p(value) / Math.log1p(cap)
: Math.sqrt(value / cap);
normalized[index] = Math.min(Math.max(scaled, 0), 1);
}
if (positiveEntries.length === 0) {
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);
@@ -255,12 +297,7 @@ export function computeActivityView({
const windowStartMs = nowMs - rangeDays * 86400000;
const clippedSessions = clipSessionsToRange(sessions, windowStartMs, nowMs);
const rawBuckets = buildHeatmapBuckets(clippedSessions, windowStartMs, nowMs, maxSessionMs);
const normalizedBuckets = normalizeBuckets(
rawBuckets,
normalizeConfig.thresholdMinutes,
normalizeConfig.capPercentile,
normalizeConfig.mode
);
const normalizedBuckets = normalizeBuckets(rawBuckets, { ...normalizeConfig, rangeDays });
const { peakDay, peakTime } = computePeaksFromBuckets(rawBuckets, dayLabels);
return {
rangeDays,
@@ -298,12 +335,7 @@ export function computeOverlapView({
const targetMinutes = sum(targetBuckets);
const denominator = Math.min(selfMinutes, targetMinutes);
const overlapPercent = denominator > 0 ? Math.round((overlapMinutes / denominator) * 100) : 0;
const normalizedBuckets = normalizeBuckets(
rawBuckets,
normalizeConfig.thresholdMinutes,
normalizeConfig.capPercentile,
normalizeConfig.mode
);
const normalizedBuckets = normalizeBuckets(rawBuckets, { ...normalizeConfig, rangeDays });
const bestOverlapTime = overlapMinutes > 0
? findBestOverlapTimeFromBuckets(rawBuckets, dayLabels)
: '';
@@ -371,3 +403,22 @@ function zeroHourRange(day, startHour, endHour, rawBuckets, selfBuckets, targetB
function sum(values) {
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;
}

View File

@@ -571,33 +571,37 @@ function pickActivityNormalizeConfig(isSelf, rangeDays) {
const role = isSelf ? 'self' : 'friend';
return {
self: {
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' },
30: { thresholdMinutes: 10, capPercentile: 95, mode: 'sqrt' },
90: { thresholdMinutes: 20, capPercentile: 90, mode: 'log' },
180: { thresholdMinutes: 30, capPercentile: 85, mode: 'log' }
7: { floorPercentile: 10, capPercentile: 80, rankWeight: 0.15, targetCoverage: 0.12, targetVolume: 40 },
30: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.25, targetVolume: 60 },
90: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.30, targetVolume: 50 },
180: { floorPercentile: 20, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.35, targetVolume: 40 }
},
friend: {
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' },
30: { thresholdMinutes: 10, capPercentile: 95, mode: 'sqrt' },
90: { thresholdMinutes: 20, capPercentile: 90, mode: 'log' },
180: { thresholdMinutes: 30, capPercentile: 85, mode: 'log' }
7: { floorPercentile: 10, capPercentile: 80, rankWeight: 0.15, targetCoverage: 0.12, targetVolume: 40 },
30: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.25, targetVolume: 60 },
90: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.30, targetVolume: 50 },
180: { floorPercentile: 20, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.35, targetVolume: 40 }
}
}[role][rangeDays] || {
thresholdMinutes: 10,
capPercentile: 95,
mode: 'sqrt'
floorPercentile: 15,
capPercentile: 85,
rankWeight: 0.20,
targetCoverage: 0.25,
targetVolume: 60
};
}
function pickOverlapNormalizeConfig(rangeDays) {
return {
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' },
30: { thresholdMinutes: 5, capPercentile: 95, mode: 'sqrt' },
90: { thresholdMinutes: 10, capPercentile: 90, mode: 'log' },
180: { thresholdMinutes: 15, capPercentile: 85, mode: 'log' }
7: { floorPercentile: 10, capPercentile: 80, rankWeight: 0.15, targetCoverage: 0.08, targetVolume: 15 },
30: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.15, targetVolume: 25 },
90: { floorPercentile: 15, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.18, targetVolume: 20 },
180: { floorPercentile: 20, capPercentile: 85, rankWeight: 0.20, targetCoverage: 0.20, targetVolume: 15 }
}[rangeDays] || {
thresholdMinutes: 5,
capPercentile: 95,
mode: 'sqrt'
floorPercentile: 15,
capPercentile: 85,
rankWeight: 0.20,
targetCoverage: 0.15,
targetVolume: 25
};
}

View File

@@ -57,12 +57,13 @@ self.addEventListener('message', (event) => {
};
break;
case 'normalizeHeatmapBuckets':
if ('thresholdMinutes' in payload || 'mode' in payload) {
console.warn('[activityWorker] normalizeHeatmapBuckets received legacy payload fields (thresholdMinutes/mode). Use payload.config instead.');
}
result = {
normalized: normalizeBuckets(
payload.buckets || [],
payload.thresholdMinutes,
payload.capPercentile,
payload.mode
payload.config || {}
)
};
break;