diff --git a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue index b14e502e..5821c0f2 100644 --- a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue +++ b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue @@ -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 }); diff --git a/src/shared/utils/activityEngine.js b/src/shared/utils/activityEngine.js index 08ddec75..86fa419d 100644 --- a/src/shared/utils/activityEngine.js +++ b/src/shared/utils/activityEngine.js @@ -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; +} diff --git a/src/stores/activity.js b/src/stores/activity.js index ce3c60b3..b34eb9ec 100644 --- a/src/stores/activity.js +++ b/src/stores/activity.js @@ -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 }; } diff --git a/src/workers/activityWorker.js b/src/workers/activityWorker.js index e8c2ac38..befe8114 100644 --- a/src/workers/activityWorker.js +++ b/src/workers/activityWorker.js @@ -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;