mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 05:43:51 +02:00
improve heatmap ui
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user