hanzio 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +510 -0
- package/dist/api-wrapper/index.d.ts +84 -0
- package/dist/api-wrapper/index.js +250 -0
- package/dist/array/index.d.ts +22 -0
- package/dist/cache/index.d.ts +19 -0
- package/dist/date/index.d.ts +11 -0
- package/dist/gzip/index.d.ts +2 -0
- package/dist/gzip/index.js +16 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1565 -0
- package/dist/jwt/index.d.ts +3 -0
- package/dist/jwt/index.js +19 -0
- package/dist/math/index.d.ts +2 -0
- package/dist/math/linearRegressionTrend.d.ts +5 -0
- package/dist/math/metricsSeriesColors.d.ts +42 -0
- package/dist/p-queue/index.d.ts +95 -0
- package/dist/p-queue/index.js +439 -0
- package/dist/promise/index.d.ts +21 -0
- package/dist/sitemap/index.d.ts +14 -0
- package/dist/sitemap/index.js +552 -0
- package/dist/state/index.d.ts +101 -0
- package/dist/string/index.d.ts +8 -0
- package/dist/typedswitch/index.d.ts +124 -0
- package/dist/url/index.d.ts +1 -0
- package/dist/zod/index.d.ts +8 -0
- package/dist/zod/index.js +210 -0
- package/dist/zod/jsonToZodSchemaMapper.d.ts +6 -0
- package/dist/zod/zodMinMaxFilter.d.ts +14 -0
- package/dist/zod/zodToTypeString.d.ts +9 -0
- package/package.json +102 -0
- package/src/api-wrapper/index.ts +492 -0
- package/src/array/index.ts +187 -0
- package/src/cache/index.ts +154 -0
- package/src/date/index.ts +54 -0
- package/src/gzip/index.ts +29 -0
- package/src/index.ts +12 -0
- package/src/jwt/index.ts +25 -0
- package/src/math/index.ts +11 -0
- package/src/math/linearRegressionTrend.ts +36 -0
- package/src/math/metricsSeriesColors.ts +208 -0
- package/src/p-queue/index.ts +690 -0
- package/src/promise/index.ts +73 -0
- package/src/sitemap/index.ts +195 -0
- package/src/state/index.ts +331 -0
- package/src/string/index.ts +86 -0
- package/src/typedswitch/index.ts +309 -0
- package/src/url/index.ts +5 -0
- package/src/zod/index.ts +29 -0
- package/src/zod/jsonToZodSchemaMapper.ts +44 -0
- package/src/zod/zodMinMaxFilter.ts +28 -0
- package/src/zod/zodToTypeString.ts +222 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
type CacheEntry<T> = {
|
|
2
|
+
value: T
|
|
3
|
+
timestamp: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type AnyFunction = (...args: never[]) => unknown
|
|
7
|
+
type CachedFunction<T extends AnyFunction> = {
|
|
8
|
+
(...args: Parameters<T>): ReturnType<T>
|
|
9
|
+
clearCache: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type CacheFunctionOptions<T extends AnyFunction> = {
|
|
13
|
+
name: string
|
|
14
|
+
fn: T
|
|
15
|
+
cacheTimeMs?: number
|
|
16
|
+
refreshInBackground?: boolean
|
|
17
|
+
cacheKeyArgs?: number[]
|
|
18
|
+
cacheKeyFn?: (...args: Parameters<T>) => unknown[]
|
|
19
|
+
/** Called when a background refresh fails (default: silent). */
|
|
20
|
+
onBackgroundRefreshError?: (error: unknown, context: { name: string }) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function cacheFunction<T extends AnyFunction>(
|
|
24
|
+
options: CacheFunctionOptions<T>
|
|
25
|
+
): CachedFunction<T> {
|
|
26
|
+
const {
|
|
27
|
+
name,
|
|
28
|
+
fn,
|
|
29
|
+
cacheTimeMs = 10 * 60 * 1000,
|
|
30
|
+
refreshInBackground = false,
|
|
31
|
+
cacheKeyArgs,
|
|
32
|
+
cacheKeyFn,
|
|
33
|
+
onBackgroundRefreshError
|
|
34
|
+
} = options
|
|
35
|
+
|
|
36
|
+
const cache = new Map<string, CacheEntry<ReturnType<T>>>()
|
|
37
|
+
const pendingPromises = new Map<string, Promise<unknown>>()
|
|
38
|
+
|
|
39
|
+
const cleanupInterval = setInterval(
|
|
40
|
+
() => {
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
for (const [key, entry] of cache.entries()) {
|
|
43
|
+
if (now - entry.timestamp >= cacheTimeMs) {
|
|
44
|
+
cache.delete(key)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
Math.min(cacheTimeMs / 2, 5 * 60 * 1000)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const wrappedFn = (...args: Parameters<T>): ReturnType<T> => {
|
|
52
|
+
const keyArgs = cacheKeyFn
|
|
53
|
+
? cacheKeyFn(...args)
|
|
54
|
+
: cacheKeyArgs
|
|
55
|
+
? cacheKeyArgs.map((i) => args[i])
|
|
56
|
+
: args
|
|
57
|
+
const key = `${name}:${JSON.stringify(keyArgs)}`
|
|
58
|
+
const now = Date.now()
|
|
59
|
+
|
|
60
|
+
const pending = pendingPromises.get(key)
|
|
61
|
+
if (pending) {
|
|
62
|
+
return pending as ReturnType<T>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cached = cache.get(key)
|
|
66
|
+
|
|
67
|
+
if (cached) {
|
|
68
|
+
const isCacheFresh = now - cached.timestamp < cacheTimeMs
|
|
69
|
+
|
|
70
|
+
if (!isCacheFresh && !refreshInBackground) {
|
|
71
|
+
cache.delete(key)
|
|
72
|
+
} else {
|
|
73
|
+
if (isCacheFresh && !refreshInBackground) {
|
|
74
|
+
return cached.value
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (refreshInBackground) {
|
|
78
|
+
const existingPending = pendingPromises.get(key)
|
|
79
|
+
if (existingPending) {
|
|
80
|
+
return existingPending as ReturnType<T>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const backgroundUpdate = fn(...args)
|
|
84
|
+
|
|
85
|
+
if (isPromiseLike(backgroundUpdate)) {
|
|
86
|
+
const backgroundPromise = (
|
|
87
|
+
backgroundUpdate as Promise<ReturnType<T>>
|
|
88
|
+
)
|
|
89
|
+
.then((resolvedValue) => {
|
|
90
|
+
cache.set(key, { value: resolvedValue, timestamp: now })
|
|
91
|
+
pendingPromises.delete(key)
|
|
92
|
+
return resolvedValue
|
|
93
|
+
})
|
|
94
|
+
.catch((error: unknown) => {
|
|
95
|
+
pendingPromises.delete(key)
|
|
96
|
+
onBackgroundRefreshError?.(error, { name })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
pendingPromises.set(key, backgroundPromise)
|
|
100
|
+
} else {
|
|
101
|
+
cache.set(key, {
|
|
102
|
+
value: backgroundUpdate as ReturnType<T>,
|
|
103
|
+
timestamp: now
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return cached.value
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isCacheFresh) {
|
|
111
|
+
return cached.value
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const result = fn(...args)
|
|
117
|
+
|
|
118
|
+
if (isPromiseLike(result)) {
|
|
119
|
+
const promise = (result as Promise<ReturnType<T>>)
|
|
120
|
+
.then((resolvedValue) => {
|
|
121
|
+
cache.set(key, { value: resolvedValue, timestamp: now })
|
|
122
|
+
pendingPromises.delete(key)
|
|
123
|
+
return resolvedValue
|
|
124
|
+
})
|
|
125
|
+
.catch((error) => {
|
|
126
|
+
pendingPromises.delete(key)
|
|
127
|
+
throw error
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
pendingPromises.set(key, promise)
|
|
131
|
+
return promise as ReturnType<T>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
cache.set(key, { value: result as ReturnType<T>, timestamp: now })
|
|
135
|
+
return result as ReturnType<T>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
wrappedFn.clearCache = () => {
|
|
139
|
+
cache.clear()
|
|
140
|
+
pendingPromises.clear()
|
|
141
|
+
clearInterval(cleanupInterval)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return wrappedFn
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
148
|
+
return (
|
|
149
|
+
typeof value === 'object' &&
|
|
150
|
+
value !== null &&
|
|
151
|
+
'then' in value &&
|
|
152
|
+
typeof value.then === 'function'
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculate the previous period date range for a given period type.
|
|
3
|
+
*/
|
|
4
|
+
export function getPreviousReportPeriod(
|
|
5
|
+
periodType: 'MONTHLY' | 'QUARTERLY' | 'YEARLY',
|
|
6
|
+
referenceDate: Date = new Date()
|
|
7
|
+
): { start: Date; end: Date } {
|
|
8
|
+
const month = referenceDate.getMonth()
|
|
9
|
+
const year = referenceDate.getFullYear()
|
|
10
|
+
|
|
11
|
+
switch (periodType) {
|
|
12
|
+
case 'MONTHLY': {
|
|
13
|
+
const prevMonth = month === 0 ? 11 : month - 1
|
|
14
|
+
const prevYear = month === 0 ? year - 1 : year
|
|
15
|
+
return {
|
|
16
|
+
start: new Date(prevYear, prevMonth, 1),
|
|
17
|
+
end: new Date(year, month, 0)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
case 'QUARTERLY': {
|
|
21
|
+
const qStart = Math.floor(month / 3) * 3
|
|
22
|
+
const prevQStart = qStart === 0 ? 9 : qStart - 3
|
|
23
|
+
const prevQYear = qStart === 0 ? year - 1 : year
|
|
24
|
+
return {
|
|
25
|
+
start: new Date(prevQYear, prevQStart, 1),
|
|
26
|
+
end: new Date(prevQYear, prevQStart + 3, 0)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
case 'YEARLY': {
|
|
30
|
+
return {
|
|
31
|
+
start: new Date(year - 1, 0, 1),
|
|
32
|
+
end: new Date(year - 1, 11, 31)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format a date range as a human-readable period label.
|
|
40
|
+
*/
|
|
41
|
+
export function formatPeriodLabel(
|
|
42
|
+
start: Date | string,
|
|
43
|
+
end: Date | string,
|
|
44
|
+
includeDay = false
|
|
45
|
+
): string {
|
|
46
|
+
const s = start instanceof Date ? start : new Date(start)
|
|
47
|
+
const e = end instanceof Date ? end : new Date(end)
|
|
48
|
+
const opts: Intl.DateTimeFormatOptions = {
|
|
49
|
+
year: 'numeric',
|
|
50
|
+
month: 'long',
|
|
51
|
+
...(includeDay && { day: 'numeric' })
|
|
52
|
+
}
|
|
53
|
+
return `${s.toLocaleDateString('en-GB', opts)} — ${e.toLocaleDateString('en-GB', opts)}`
|
|
54
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import pako from 'pako'
|
|
2
|
+
|
|
3
|
+
export function compressString(input: string): string {
|
|
4
|
+
// Convert string to Uint8Array
|
|
5
|
+
const uint8Array = new TextEncoder().encode(input)
|
|
6
|
+
|
|
7
|
+
// Compress the Uint8Array
|
|
8
|
+
const compressed = pako.deflate(uint8Array)
|
|
9
|
+
|
|
10
|
+
// Convert compressed Uint8Array to base64 string for storage
|
|
11
|
+
return btoa(
|
|
12
|
+
String.fromCharCode.apply(null, compressed as unknown as number[])
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function decompressString(compressedString: string): string {
|
|
17
|
+
// Convert base64 string back to Uint8Array
|
|
18
|
+
const compressedData = new Uint8Array(
|
|
19
|
+
atob(compressedString)
|
|
20
|
+
.split('')
|
|
21
|
+
.map((char) => char.charCodeAt(0))
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// Decompress the Uint8Array
|
|
25
|
+
const decompressed = pako.inflate(compressedData)
|
|
26
|
+
|
|
27
|
+
// Convert Uint8Array back to string
|
|
28
|
+
return new TextDecoder().decode(decompressed)
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './typedswitch'
|
|
2
|
+
export * from './array'
|
|
3
|
+
export * from './string'
|
|
4
|
+
export * from './promise'
|
|
5
|
+
export * from './cache'
|
|
6
|
+
export * from './state'
|
|
7
|
+
export * from './math'
|
|
8
|
+
export * from './date'
|
|
9
|
+
export * from './url'
|
|
10
|
+
export * from './zod'
|
|
11
|
+
export * from './api-wrapper'
|
|
12
|
+
export * from './p-queue'
|
package/src/jwt/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as jose from 'jose'
|
|
2
|
+
|
|
3
|
+
export async function jwtSign<_Payload extends object>(
|
|
4
|
+
payload: jose.JWTPayload,
|
|
5
|
+
secret: string
|
|
6
|
+
) {
|
|
7
|
+
const secretKey = new TextEncoder().encode(secret)
|
|
8
|
+
return await new jose.SignJWT(payload)
|
|
9
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
10
|
+
.setIssuedAt()
|
|
11
|
+
.sign(secretKey)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function jwtVerify<Payload extends object>(
|
|
15
|
+
token: string,
|
|
16
|
+
secret: string
|
|
17
|
+
): Promise<Payload | null> {
|
|
18
|
+
try {
|
|
19
|
+
const secretKey = new TextEncoder().encode(secret)
|
|
20
|
+
const { payload } = await jose.jwtVerify(token, secretKey)
|
|
21
|
+
return payload as Payload
|
|
22
|
+
} catch (_error) {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { linearRegressionTrend } from './linearRegressionTrend'
|
|
2
|
+
export {
|
|
3
|
+
createResolveColorsByKeys,
|
|
4
|
+
getHeatmapCellStyle,
|
|
5
|
+
hashStringToColorIndex,
|
|
6
|
+
METRICS_SERIES_PALETTE_SIZE,
|
|
7
|
+
type ResolveColorsByKeysResult,
|
|
8
|
+
resolveSeriesColorMap,
|
|
9
|
+
seriesColorKey,
|
|
10
|
+
withAlpha
|
|
11
|
+
} from './metricsSeriesColors'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Least-squares linear regression for y at x = 0, 1, …, n − 1.
|
|
3
|
+
* Returns fitted y for each index, or [] if n < 2 or any input is non-finite.
|
|
4
|
+
*/
|
|
5
|
+
export function linearRegressionTrend(values: number[]): number[] {
|
|
6
|
+
const n = values.length
|
|
7
|
+
if (n < 2) return []
|
|
8
|
+
for (const v of values) {
|
|
9
|
+
if (!Number.isFinite(v)) return []
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let sumX = 0
|
|
13
|
+
let sumY = 0
|
|
14
|
+
let sumXY = 0
|
|
15
|
+
let sumX2 = 0
|
|
16
|
+
for (let i = 0; i < n; i++) {
|
|
17
|
+
const x = i
|
|
18
|
+
const y = values[i]!
|
|
19
|
+
sumX += x
|
|
20
|
+
sumY += y
|
|
21
|
+
sumXY += x * y
|
|
22
|
+
sumX2 += x * x
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const denom = n * sumX2 - sumX * sumX
|
|
26
|
+
if (denom === 0) return []
|
|
27
|
+
|
|
28
|
+
const slope = (n * sumXY - sumX * sumY) / denom
|
|
29
|
+
const intercept = (sumY - slope * sumX) / n
|
|
30
|
+
|
|
31
|
+
const out: number[] = []
|
|
32
|
+
for (let i = 0; i < n; i++) {
|
|
33
|
+
out.push(intercept + slope * i)
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared metrics chart series color assignment for web (CSS tokens) and Expo (hex palette).
|
|
3
|
+
* Same hash + collision strategy so labels map to the same slot index everywhere.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Matches PrimeVue semantic palette slot count (cyan…blue) and Expo SERIES_COLORS length */
|
|
7
|
+
export const METRICS_SERIES_PALETTE_SIZE = 10
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* FNV-1a 32-bit hash — stable palette index for a string key.
|
|
11
|
+
*/
|
|
12
|
+
export function hashStringToColorIndex(str: string): number {
|
|
13
|
+
let hash = 2166136261
|
|
14
|
+
for (let i = 0; i < str.length; i++) {
|
|
15
|
+
hash ^= str.charCodeAt(i)
|
|
16
|
+
hash = Math.imul(hash, 16777619)
|
|
17
|
+
}
|
|
18
|
+
return Math.abs(hash) % METRICS_SERIES_PALETTE_SIZE
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function circularHueDistance(
|
|
22
|
+
a: number,
|
|
23
|
+
b: number,
|
|
24
|
+
paletteSize: number
|
|
25
|
+
): number {
|
|
26
|
+
const diff = Math.abs(a - b)
|
|
27
|
+
return Math.min(diff, paletteSize - diff)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pickHueMaxMinDistance(
|
|
31
|
+
candidates: number[],
|
|
32
|
+
used: Set<number>,
|
|
33
|
+
paletteSize: number
|
|
34
|
+
): number {
|
|
35
|
+
let best = candidates[0] ?? 0
|
|
36
|
+
let bestScore = -1
|
|
37
|
+
for (const cand of candidates) {
|
|
38
|
+
let minD = paletteSize
|
|
39
|
+
for (const u of used) {
|
|
40
|
+
minD = Math.min(minD, circularHueDistance(cand, u, paletteSize))
|
|
41
|
+
}
|
|
42
|
+
if (minD > bestScore || (minD === bestScore && cand < best)) {
|
|
43
|
+
bestScore = minD
|
|
44
|
+
best = cand
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return best
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Prefer human-visible label so charts and tables agree on the same color key.
|
|
52
|
+
*/
|
|
53
|
+
export function seriesColorKey(label: string | undefined, key: string): string {
|
|
54
|
+
const fromLabel = label?.trim()
|
|
55
|
+
if (fromLabel) return fromLabel
|
|
56
|
+
return key.trim() || key
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Map each distinct key to a palette index (hash preference, then max-min circular spacing).
|
|
61
|
+
*/
|
|
62
|
+
export function resolveSeriesColorMap(keys: string[]): Map<string, number> {
|
|
63
|
+
const result = new Map<string, number>()
|
|
64
|
+
const uniqueKeys = [...new Set(keys)]
|
|
65
|
+
if (uniqueKeys.length === 0) return result
|
|
66
|
+
|
|
67
|
+
const paletteSize = METRICS_SERIES_PALETTE_SIZE
|
|
68
|
+
|
|
69
|
+
if (uniqueKeys.length === 1) {
|
|
70
|
+
const only = uniqueKeys[0]!
|
|
71
|
+
result.set(only, hashStringToColorIndex(only))
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const usedIndices = new Set<number>()
|
|
76
|
+
const entries = uniqueKeys.map((key) => ({
|
|
77
|
+
key,
|
|
78
|
+
preferred: hashStringToColorIndex(key)
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (!usedIndices.has(entry.preferred)) {
|
|
83
|
+
result.set(entry.key, entry.preferred)
|
|
84
|
+
usedIndices.add(entry.preferred)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const unassigned = entries.filter((e) => !result.has(e.key))
|
|
89
|
+
for (const entry of unassigned) {
|
|
90
|
+
const unused: number[] = []
|
|
91
|
+
for (let i = 0; i < paletteSize; i++) {
|
|
92
|
+
if (!usedIndices.has(i)) unused.push(i)
|
|
93
|
+
}
|
|
94
|
+
const candidates =
|
|
95
|
+
unused.length > 0
|
|
96
|
+
? unused
|
|
97
|
+
: Array.from({ length: paletteSize }, (_, i) => i)
|
|
98
|
+
const idx = pickHueMaxMinDistance(candidates, usedIndices, paletteSize)
|
|
99
|
+
result.set(entry.key, idx)
|
|
100
|
+
usedIndices.add(idx)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return result
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type ResolveColorsByKeysResult = {
|
|
107
|
+
getColor: (key: string) => string
|
|
108
|
+
getHoverColor: (key: string) => string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve stable colors per key using palette index → RGB/CSS from the caller.
|
|
113
|
+
*/
|
|
114
|
+
export function createResolveColorsByKeys(
|
|
115
|
+
keys: string[],
|
|
116
|
+
getSeriesColor: (index: number) => string,
|
|
117
|
+
getSeriesHoverColor: (index: number) => string
|
|
118
|
+
): ResolveColorsByKeysResult {
|
|
119
|
+
const colorMap = resolveSeriesColorMap(keys)
|
|
120
|
+
return {
|
|
121
|
+
getColor: (key: string) =>
|
|
122
|
+
getSeriesColor(colorMap.get(key) ?? hashStringToColorIndex(key)),
|
|
123
|
+
getHoverColor: (key: string) =>
|
|
124
|
+
getSeriesHoverColor(colorMap.get(key) ?? hashStringToColorIndex(key))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function clamp(value: number, min: number, max: number): number {
|
|
129
|
+
return Math.min(max, Math.max(min, value))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Add alpha to hex, rgb(), or rgba() strings.
|
|
134
|
+
*/
|
|
135
|
+
export function withAlpha(color: string, alpha: number): string {
|
|
136
|
+
const trimmed = color.trim()
|
|
137
|
+
|
|
138
|
+
if (trimmed.startsWith('#')) {
|
|
139
|
+
const hex = trimmed.slice(1)
|
|
140
|
+
const normalized =
|
|
141
|
+
hex.length === 3
|
|
142
|
+
? hex
|
|
143
|
+
.split('')
|
|
144
|
+
.map((c) => c + c)
|
|
145
|
+
.join('')
|
|
146
|
+
: hex
|
|
147
|
+
if (normalized.length === 6) {
|
|
148
|
+
const r = Number.parseInt(normalized.slice(0, 2), 16)
|
|
149
|
+
const g = Number.parseInt(normalized.slice(2, 4), 16)
|
|
150
|
+
const b = Number.parseInt(normalized.slice(4, 6), 16)
|
|
151
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (trimmed.startsWith('rgb(')) {
|
|
156
|
+
return trimmed.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (trimmed.startsWith('rgba(')) {
|
|
160
|
+
return trimmed.replace(/,\s*[\d.]+\)$/, `, ${alpha})`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return trimmed
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Heatmap cell background from normalized value intensity.
|
|
168
|
+
*/
|
|
169
|
+
export function getHeatmapCellStyle(params: {
|
|
170
|
+
value: number | null | undefined
|
|
171
|
+
min: number
|
|
172
|
+
max: number
|
|
173
|
+
baseColor: string
|
|
174
|
+
neutralColor?: string
|
|
175
|
+
minAlpha?: number
|
|
176
|
+
maxAlpha?: number
|
|
177
|
+
}): Record<string, string> {
|
|
178
|
+
const {
|
|
179
|
+
value,
|
|
180
|
+
min,
|
|
181
|
+
max,
|
|
182
|
+
baseColor,
|
|
183
|
+
neutralColor,
|
|
184
|
+
minAlpha = 0.08,
|
|
185
|
+
maxAlpha = 0.78
|
|
186
|
+
} = params
|
|
187
|
+
|
|
188
|
+
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
189
|
+
return {}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
193
|
+
return {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (max <= min) {
|
|
197
|
+
return {
|
|
198
|
+
backgroundColor: withAlpha(neutralColor ?? baseColor, 0.32)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const normalized = clamp((value - min) / (max - min), 0, 1)
|
|
203
|
+
const alpha = minAlpha + (maxAlpha - minAlpha) * normalized
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
backgroundColor: withAlpha(baseColor, alpha)
|
|
207
|
+
}
|
|
208
|
+
}
|