chore: adjust colors generator

This commit is contained in:
jialin
2026-06-25 14:34:15 +08:00
committed by jialin
parent 1cf146ca32
commit 1d7543f19c
2 changed files with 118 additions and 40 deletions
+113 -36
View File
@@ -1,55 +1,132 @@
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
import { converter, modeHsl, modeRgb, useMode } from 'culori/fn';
import { clampChroma, formatHex, modeOklch, modeRgb, useMode } from 'culori/fn';
import useUserSettings from './use-user-settings';
const toHsl = converter('hsl');
const DEFAULT_BRAND_HUE = 211; // brand color (HSL hue)
// Cool palette spans blue -> violet. The range starts just above the brand hue
// so generated colors never sit right next to the brand color.
const COOL_HUE_START = 215;
const COOL_HUE_END = 310;
// Linear-style minimal/neutral palette: a TIGHT blue -> violet band in OKLCH
// (perceptually uniform), kept moderate-chroma so fills read as clean and gentle
// rather than candy-colored. We deliberately stay narrow and DON'T fan out to
// green/magenta — in this aesthetic series are separated by LIGHTNESS, not by
// spreading across the hue wheel. Every series (including the first) is generated
// from this one ramp, so the whole palette stays in a single cohesive color
// family — no special high-saturation brand color that clashes with the rest.
const COOL_HUE_START = 250; // blue
const COOL_HUE_END = 315; // violet (stops short of magenta/pink, stays neutral-cool)
const COOL_HUE_RANGE = COOL_HUE_END - COOL_HUE_START;
function colorStringToHue(input?: string): number | undefined {
if (!input) return undefined;
const color = toHsl(input);
if (!color || typeof color.h !== 'number') return undefined;
return color.h;
}
export default function useCoolColors() {
// const brandHue = useMemo(
// () => colorStringToHue(userSettings.colorPrimary) ?? DEFAULT_BRAND_HUE,
// [userSettings.colorPrimary]
// );
/**
* Vivid, distinct cool accents — for places that need a handful of "primary"
* colors, one per card/section (e.g. the summary trend cards), NOT a stacked
* multi-series palette. Every color is anchor-quality (bright + saturated) and
* spread evenly across the blue→violet band, so the set reads as several equally
* strong primaries rather than one bold + several washed-out fills.
*/
export function useCoolAccents() {
useMode(modeRgb);
useMode(modeHsl);
useMode(modeOklch);
const brandHue = DEFAULT_BRAND_HUE;
const { isDarkTheme } = useUserSettings();
return useMemoizedFn((count: number): string[] => {
if (count <= 0) return [];
// First series keeps the brand color.
const colors: string[] = [`hsl(${brandHue}, 100%, 50%)`];
const l = isDarkTheme ? 0.62 : 0.64;
const c = isDarkTheme ? 0.16 : 0.2;
const out: string[] = [];
for (let i = 0; i < count; i++) {
const t = count <= 1 ? 0 : i / (count - 1);
const hue = COOL_HUE_START + t * COOL_HUE_RANGE;
out.push(
formatHex(clampChroma({ mode: 'oklch', l, c, h: hue }, 'oklch'))
);
}
return out;
});
}
export default function useCoolColors() {
useMode(modeRgb);
useMode(modeOklch);
const { isDarkTheme } = useUserSettings();
return useMemoizedFn((count: number): string[] => {
if (count <= 0) return [];
// Moderate chroma: clean and crisp, but gentle (not neon). Too low reads as
// muddy/dirty; too high reads as harsh. Dark mode a touch lower so fills
// stay calm against the dark canvas.
const baseChroma = isDarkTheme ? 0.085 : 0.12;
// First series is the "primary" anchor: SAME blue family (the bluest end of
// the ramp, nearest the brand hue) but clearly brighter and more saturated —
// a vivid, clean brand-blue that reads as the base color. The rest of the
// palette stays low-chroma, so the anchor pops as the primary while the
// family still feels cohesive.
const colors: string[] = [
formatHex(
clampChroma(
{
mode: 'oklch',
l: isDarkTheme ? 0.62 : 0.66,
c: isDarkTheme ? 0.16 : 0.2,
h: COOL_HUE_START
},
'oklch'
)
)
];
const rest = count - 1;
if (rest <= 0) return colors;
// Separation is driven by LIGHTNESS, not hue. The hue band is narrow, so as
// the count grows we add lightness TIERS — each tier reuses the same narrow
// hue ramp at a distinct lightness level, multiplying how many separable
// colors fit while keeping the whole palette in one cohesive family.
const tiers = rest <= 6 ? 1 : rest <= 12 ? 2 : 3;
const steps = Math.ceil(rest / tiers);
// Distinct lightness levels per tier count (index 0 → 2 tiers, 1 → 3 tiers).
// Lighter, airier levels for a fresh/crisp feel; kept in the upper-mid range
// so fills stay clean and legible.
const lightTiers = isDarkTheme
? [
[0.68, 0.5],
[0.72, 0.6, 0.48]
]
: [
[0.82, 0.64],
[0.84, 0.72, 0.6]
];
// Single-tier light/dark zig-zag for the common small-count case.
const [lightLo, lightHi] = isDarkTheme ? [0.5, 0.68] : [0.64, 0.82];
for (let i = 0; i < rest; i++) {
// Evenly divide the hue range. Because `count` is known up front, even
// spacing guarantees the largest possible minimum gap between any two
// hues — golden-ratio sampling can land two values close together after
// wrapping into this narrow cool band.
const t = rest <= 1 ? 0.5 : (i + 0.5) / rest;
// Cycle the tier on every step so consecutive series always differ in
// lightness — exactly where stacked bars are hardest to tell apart.
const tier = i % tiers;
const step = Math.floor(i / tiers);
// Even hue ramp across the narrow band. Each tier is offset by a fraction
// of a step so same-step colors in different tiers don't share a hue.
const t = steps <= 1 ? 0.5 : (step + tier / tiers) / steps;
const hue = COOL_HUE_START + t * COOL_HUE_RANGE;
// Zig-zag saturation/lightness on a second axis. HSL is not perceptually
// uniform (blues/purples look alike at equal hue steps), so alternating
// lightness is what keeps adjacent series clearly distinct.
const saturation = 72 + (i % 3) * 9; // 72 / 81 / 90
const lightness = i % 2 ? 46 : 62; // alternate darker / lighter
// 1 tier (≤6 series): light/dark zig-zag. 2-3 tiers: the tier's level.
const lightness =
tiers === 1 ? (i % 2 ? lightLo : lightHi) : lightTiers[tiers - 2][tier];
colors.push(`hsl(${Math.round(hue)}, ${saturation}%, ${lightness}%)`);
// Clamp chroma into the sRGB gamut so values don't get distorted by a raw
// channel clip when serialized to hex.
colors.push(
formatHex(
clampChroma(
{ mode: 'oklch', l: lightness, c: baseChroma, h: hue },
'oklch'
)
)
);
}
return colors;
+5 -4
View File
@@ -21,7 +21,7 @@
* each domain's natural unit (tokens / GPU-Hours / GB-Days); a true
* cross-resource split needs a common unit.
*/
import useCoolColors from '@/hooks/use-cool-colors';
import { useCoolAccents } from '@/hooks/use-cool-colors';
import BarChart from '@/pages/_components/bar-chart';
import PieChart from '@/pages/_components/pie-chart';
import { formatLargeNumber } from '@/utils';
@@ -209,7 +209,8 @@ const SummaryTab: React.FC = () => {
const access = useAccess();
const intl = useIntl();
const t = (id: string) => intl.formatMessage({ id });
const coolColors = useCoolColors()(8);
// One vivid primary per summary card (Tokens / Compute / Storage).
const coolColors = useCoolAccents()(3);
// No All/My dropdown (matches the Tokens tab): managers see the org-wide
// view and narrow it with the user filter, others only their own rows.
@@ -499,13 +500,13 @@ const SummaryTab: React.FC = () => {
<Col span={24}>
<DomainSection
title={t('usage.tabs.storage')}
accent={coolColors[3]}
accent={coolColors[2]}
donutData={storageDonut}
donutTotalLabel={t('usage.metric.gbDays')}
trendTitle={t('usage.summary.gbDaysOverTime')}
trendXAxis={storageTrend.xAxis}
trendData={storageTrend.data}
trendColor={coolColors[3]}
trendColor={coolColors[2]}
trendGran={granularity}
pieLoading={storageByTypeLoading}
barLoading={storageByDateLoading}