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