Skip to content

Commit d39253b

Browse files
committed
Restore icon-derived buff colours with readable contrast
1 parent b2c7954 commit d39253b

16 files changed

Lines changed: 263 additions & 85 deletions

src/components/Abilities/BuffBuilder.tsx

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import React, { useEffect, useRef } from 'react';
1+
import React, { useEffect } from 'react';
22
import styled from 'styled-components'
33
import { InputNumber } from 'antd'
44
import { DataStatus } from '@/app/api/xivapi/types'
55
import { StatusIcon } from './StatusIcon'
6-
import ColorThief from 'colorthief'
76
import { useTranslation } from '@/context/LanguageContext'
87

9-
const colorThief = new ColorThief();
108

119
const BuffBuilderContainer = styled.div`
1210
display: flex;
@@ -47,23 +45,13 @@ const ActionInfo = styled.div`
4745
gap: 4px;
4846
`;
4947

50-
const componentToHex = (c: number) => {
51-
var hex = c.toString(16);
52-
return hex.length == 1 ? "0" + hex : hex;
53-
}
54-
55-
const rgbToHex = (r: number, g: number, b: number) => {
56-
return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b);
57-
}
58-
5948
interface BuffBuilderProps {
6049
status: DataStatus
6150
applicationDelay: number | null
6251
setApplicationDelay: (applicationDelay: number | null) => void
6352
duration: number | null
6453
setDuration: (duration: number | null) => void
6554
color: string | undefined
66-
setColor: (color: string | undefined) => void
6755
onCreate: () => void
6856
}
6957

@@ -74,34 +62,9 @@ export const BuffBuilder: React.FC<BuffBuilderProps> = ({
7462
duration,
7563
setDuration,
7664
color,
77-
setColor,
7865
onCreate,
7966
}) => {
8067
const { t } = useTranslation()
81-
const imageRef = useRef<HTMLImageElement>(null);
82-
83-
const getDominantColor = async () => {
84-
if (!imageRef.current) {
85-
return;
86-
}
87-
88-
if (imageRef.current.complete) {
89-
const color = await colorThief.getColor(imageRef.current, 1);
90-
return rgbToHex(color[0], color[1], color[2]);
91-
} else {
92-
return new Promise<string>((resolve, _) => {
93-
imageRef.current!.onload = async () => {
94-
const color = await colorThief.getColor(imageRef.current, 1);
95-
resolve(rgbToHex(color[0], color[1], color[2]));
96-
}
97-
});
98-
}
99-
}
100-
101-
useEffect(() => {
102-
getDominantColor().then(setColor);
103-
}, [status, setColor]);
104-
10568
useEffect(onCreate, [duration, applicationDelay, onCreate, color]);
10669

10770
const idLabel = status.id.length > 8 ? t('buffBuilder.custom') : status.id;
@@ -112,7 +75,6 @@ export const BuffBuilder: React.FC<BuffBuilderProps> = ({
11275
<ActionDisplayAndSettingsColumn>
11376
{status.icon &&
11477
<StatusIcon
115-
ref={imageRef}
11678
status={status}
11779
width={60}
11880
/>

src/components/Abilities/BuffSelect.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const BuffSelect: React.FC<BuffSelectProps> = ({ job, setStatus, preloade
3535
const [currentStatus, setCurrentStatus] = useState<DataStatus | null>(null);
3636
const [applicationDelay, setApplicationDelay] = useState<number | null>(0);
3737
const [duration, setDuration] = useState<number | null>(20);
38-
const [color, setColor] = useState<string>();
38+
const [color, setColor] = useState<string>('auto');
3939

4040
// Effect to populate fields from preloaded status
4141
useEffect(() => {
@@ -62,7 +62,7 @@ export const BuffSelect: React.FC<BuffSelectProps> = ({ job, setStatus, preloade
6262
id: currentStatus.id,
6363
name: currentStatus.name ?? t('buffBuilder.unknown'),
6464
imageSrc: currentStatus.icon.toString(),
65-
color: color ?? '#000000',
65+
color: color ?? 'auto',
6666
duration: duration ?? 0,
6767
applicationDelay: applicationDelay ?? 0,
6868
};
@@ -96,7 +96,6 @@ export const BuffSelect: React.FC<BuffSelectProps> = ({ job, setStatus, preloade
9696
duration={duration}
9797
setDuration={setDuration}
9898
color={color}
99-
setColor={setColor}
10099
onCreate={onCreate}
101100
/>
102101
);

src/components/Canvas/Canvas.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CanvasTextMeasurer } from './textLayout'
88
import { loadRenderImages, paintRenderPlan } from './paintRenderPlan'
99
import { Action, LayoutViolation } from './types'
1010
import { styles } from './styles'
11+
import { resolveBuffColors } from '@/lib/buffColors'
1112

1213
const Viewport = styled.div`
1314
position: relative;
@@ -175,9 +176,11 @@ const Canvas = forwardRef<HTMLCanvasElement, CanvasProps>((props, ref) => {
175176
if (!context) throw new Error('Canvas 2D rendering is unavailable.')
176177

177178
const measurer = new CanvasTextMeasurer(context)
179+
const [resolvedPrepull, resolvedRotation] = await Promise.all([resolveBuffColors(prepullRotation), resolveBuffColors(rotation)])
180+
if (abortController.signal.aborted || currentGeneration !== generation.current) return
178181
const layoutInput = {
179-
prepullRotation,
180-
rotation,
182+
prepullRotation: resolvedPrepull,
183+
rotation: resolvedRotation,
181184
title,
182185
jobName,
183186
jobIcon,

src/components/Canvas/paintRenderPlan.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,7 @@ export type DecodedImages = ReadonlyMap<string, CanvasImageSource>
55
const transparentVersion = (color: string): string =>
66
/^#[0-9a-f]{6}$/i.test(color) ? `${color}00` : 'transparent'
77

8-
const loadableSource = (source: string): string => {
9-
// Proxy remote icons through Next's same-origin image optimizer. Besides
10-
// deterministic decoding, this keeps exported canvases origin-clean even
11-
// when a third-party image host does not emit CORS headers.
12-
if (/^https?:\/\//i.test(source)) {
13-
return `/_next/image?url=${encodeURIComponent(source)}&w=640&q=90`
14-
}
15-
return source
16-
}
17-
18-
export const loadRenderImages = async (sources: string[], signal?: AbortSignal): Promise<Map<string, HTMLImageElement>> => {
19-
const entries = await Promise.all(sources.map(async source => {
20-
if (signal?.aborted) throw new DOMException('Render superseded', 'AbortError')
21-
const loaded = new Image()
22-
loaded.crossOrigin = 'anonymous'
23-
const loadResult = new Promise<void>((resolve, reject) => {
24-
loaded.addEventListener('load', () => resolve(), { once: true })
25-
loaded.addEventListener('error', () => reject(new Error(`Unable to load image: ${source}`)), { once: true })
26-
})
27-
loaded.src = loadableSource(source)
28-
await loadResult
29-
try {
30-
await loaded.decode()
31-
} catch {
32-
// A completed load is drawable even where decode() is unsupported.
33-
}
34-
if (signal?.aborted) throw new DOMException('Render superseded', 'AbortError')
35-
return [source, loaded] as const
36-
}))
37-
return new Map(entries)
38-
}
8+
export { loadRenderImages } from '@/lib/iconImages'
399

4010
export const paintRenderPlan = (
4111
context: CanvasRenderingContext2D,

src/components/Canvas/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export interface Status {
22
id: string;
33
name: string;
44
imageSrc: string;
5+
// 'auto' derives a readable colour from the status icon; hex values are explicit.
56
color: string;
67
applicationDelay: number;
78
duration: number;

src/components/Editor/SequenceDetail.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { DataAction } from '@/app/api'
1414
import { useTranslation } from '@/context/LanguageContext'
1515
import { SequenceListKind } from './SequenceList'
1616

17+
import { useBuffColor } from '@/lib/useBuffColor'
18+
1719
const DEFAULT_RECAST_TIME = 2.5
1820
const DEFAULT_CAST_TIME = 0
1921
const DEFAULT_PREPULL_TIME = -5
@@ -303,6 +305,7 @@ const StatusEditor = ({ status, onUpdate, onRemove }: {
303305
const { t } = useTranslation()
304306
const id = useId()
305307
const [open, setOpen] = useState(false)
308+
const resolvedColor = useBuffColor(status.imageSrc, status.color)
306309
return <StatusCard data-testid="status-row">
307310
<StatusHeader>
308311
<Checkbox checked={status.enabled !== false} onChange={event => onUpdate({ enabled: event.target.checked })}>
@@ -322,8 +325,9 @@ const StatusEditor = ({ status, onUpdate, onRemove }: {
322325
<FieldLabel htmlFor={`${id}-delay`}>{t('buffBuilder.applicationDelay')}</FieldLabel>
323326
<DraftNumber id={`${id}-delay`} aria-label={`${status.name} ${t('buffBuilder.applicationDelay')}`} min={0} max={30} step={0.1} value={status.applicationDelay} onChange={value => onUpdate({ applicationDelay: value ?? 0 })} />
324327
<FieldLabel htmlFor={`${id}-color`}>{t('editor.statusColor')}</FieldLabel>
325-
<input id={`${id}-color`} aria-label={`${status.name} ${t('editor.statusColor')}`} type="color" value={status.color} onChange={event => onUpdate({ color: event.target.value })} />
328+
<input id={`${id}-color`} aria-label={`${status.name} ${t('editor.statusColor')}`} type="color" value={resolvedColor} onChange={event => onUpdate({ color: event.target.value })} />
326329
</Grid>
330+
<Button type="text" disabled={status.color === 'auto'} onClick={() => onUpdate({ color: 'auto' })}>{t('editor.resetStatusColor')}</Button>
327331
<Button type="text" danger aria-label={`${t('editor.removeStatus')}: ${status.name}`} onClick={onRemove}>{t('editor.removeStatus')}</Button>
328332
</StatusSettings>
329333
</div>
@@ -411,7 +415,7 @@ export const SequenceDetail = ({ job, action, list, index, onChange }: SequenceD
411415
const addStatus = (data: DataStatus) => {
412416
emit({ ...action, statusesApplied: [...statuses, {
413417
id: data.id, name: data.name ?? '', imageSrc: data.icon?.toString() ?? '',
414-
enabled: true, duration: 20, applicationDelay: 0, color: '#74d6b4',
418+
enabled: true, duration: 20, applicationDelay: 0, color: 'auto',
415419
}] })
416420
setBuffEditorOpen(false)
417421
}

src/lib/actionDefaults.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const dataActionToDefaultAction = (
8787
const status = catalogStatuses.find(item => item.id === association.statusId)
8888
return status ? [{
8989
id: String(status.id), name: '', imageSrc: status.icon,
90-
color: '#74d6b4', enabled: association.defaultEnabled ?? true,
90+
color: 'auto', enabled: association.defaultEnabled ?? true,
9191
duration: association.durationMs / 1000,
9292
applicationDelay: (association.applicationDelayMs ?? 0) / 1000,
9393
}] : []

src/lib/buffColors.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
vi.mock('@/components/Canvas/styles', () => ({ styles: { colors: { background: '#121213' } } }))
3+
const { getColor, loadRenderImages } = vi.hoisted(() => ({
4+
getColor: vi.fn(() => [40, 8, 8]),
5+
loadRenderImages: vi.fn(async (sources: string[]) => new Map(sources.map(source => [source, {}]))),
6+
}))
7+
vi.mock('colorthief', () => ({ default: class { getColor = getColor } }))
8+
vi.mock('./iconImages', () => ({ loadRenderImages }))
9+
import { automaticBuffColor, contrastRatio, FALLBACK_BUFF_COLOR, readableBuffColor, resolveBuffColors } from './buffColors'
10+
11+
describe('icon-derived buff colours', () => {
12+
it.each(['#280808', '#081828', '#182808', '#000000', '#121213'])('brightens %s to at least 3:1 contrast', color => {
13+
expect(contrastRatio(readableBuffColor(color), '#121213')).toBeGreaterThanOrEqual(3)
14+
})
15+
it('preserves readable colours and the hue of a dark red', () => {
16+
expect(readableBuffColor('#f0c674')).toBe('#f0c674')
17+
const adjusted = readableBuffColor('#280808')
18+
expect(adjusted.slice(3, 5)).toBe(adjusted.slice(5, 7))
19+
expect(parseInt(adjusted.slice(1, 3), 16)).toBeGreaterThan(parseInt(adjusted.slice(3, 5), 16))
20+
})
21+
it('shares extraction across simultaneous requests for the same icon', async () => {
22+
const before = getColor.mock.calls.length
23+
const results = await Promise.all([automaticBuffColor('/red.png'), automaticBuffColor('/red.png')])
24+
expect(results[0]).toBe(results[1])
25+
expect(getColor.mock.calls.length - before).toBe(1)
26+
})
27+
it('falls back on failure and permits a later retry', async () => {
28+
loadRenderImages.mockRejectedValueOnce(new Error('Unavailable'))
29+
expect(await automaticBuffColor('/retry.png')).toBe(FALLBACK_BUFF_COLOR)
30+
expect(await automaticBuffColor('/retry.png')).not.toBe(FALLBACK_BUFF_COLOR)
31+
expect(await automaticBuffColor('')).toBe(FALLBACK_BUFF_COLOR)
32+
})
33+
it('resolves only automatic enabled statuses without changing source actions', async () => {
34+
const statuses = ['auto', '#123456'].map((color, i) => ({ id: String(i), name: 'Buff', imageSrc: '/red.png', color, duration: 20, applicationDelay: 0 }))
35+
const actions = [{ id: '1', instanceId: '1', type: 'gcd' as const, name: 'Action', imageSrc: '', statusesApplied: [...statuses, { ...statuses[0], enabled: false }] }]
36+
const resolved = await resolveBuffColors(actions)
37+
expect(resolved[0].statusesApplied![0].color).not.toBe('auto')
38+
expect(resolved[0].statusesApplied![1].color).toBe('#123456')
39+
expect(resolved[0].statusesApplied![2].color).toBe('auto')
40+
expect(actions[0].statusesApplied[0].color).toBe('auto')
41+
})
42+
})

src/lib/buffColors.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { Action } from '@/components/Canvas/types'
2+
import { styles } from '@/components/Canvas/styles'
3+
import { loadRenderImages } from './iconImages'
4+
5+
export const FALLBACK_BUFF_COLOR = '#74d6b4'
6+
const MIN_CONTRAST = 3
7+
const rgb = (hex: string): number[] => [1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16) / 255)
8+
const hex = (channels: number[]): string => '#' + channels.map(channel => Math.round(channel * 255).toString(16).padStart(2, '0')).join('')
9+
const luminance = (color: string): number => rgb(color)
10+
.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
11+
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0)
12+
13+
export const contrastRatio = (foreground: string, background: string): number => {
14+
const a = luminance(foreground)
15+
const b = luminance(background)
16+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
17+
}
18+
19+
// Change HSL lightness only, retaining the source hue and saturation.
20+
export const readableBuffColor = (color: string, background = styles.colors.background): string => {
21+
if (contrastRatio(color, background) >= MIN_CONTRAST) return color
22+
const channels = rgb(color)
23+
const lightness = (Math.max(...channels) + Math.min(...channels)) / 2
24+
const chroma = 1 - Math.abs(2 * lightness - 1)
25+
const atLightness = (value: number) => hex(channels.map(channel => {
26+
const ratio = chroma === 0 ? 0 : (1 - Math.abs(2 * value - 1)) / chroma
27+
return Math.min(1, Math.max(0, value + (channel - lightness) * ratio))
28+
}))
29+
let lower = lightness
30+
let upper = 1
31+
for (let i = 0; i < 24; i++) {
32+
const middle = (lower + upper) / 2
33+
if (contrastRatio(atLightness(middle), background) >= MIN_CONTRAST) upper = middle
34+
else lower = middle
35+
}
36+
return atLightness(upper)
37+
}
38+
39+
const colors = new Map<string, Promise<string>>()
40+
export const automaticBuffColor = (source: string): Promise<string> => {
41+
if (!source) return Promise.resolve(FALLBACK_BUFF_COLOR)
42+
let pending = colors.get(source)
43+
if (!pending) {
44+
pending = (async () => {
45+
const [images, { default: ColorThief }] = await Promise.all([
46+
loadRenderImages([source]), import('colorthief'),
47+
])
48+
const dominant: number[] | null = new ColorThief().getColor(images.get(source)!, 1)
49+
return dominant ? readableBuffColor(hex(dominant.map(channel => channel / 255))) : FALLBACK_BUFF_COLOR
50+
})().catch(() => {
51+
colors.delete(source)
52+
return FALLBACK_BUFF_COLOR
53+
})
54+
colors.set(source, pending)
55+
if (colors.size > 256) colors.delete(colors.keys().next().value!)
56+
}
57+
return pending
58+
}
59+
60+
export const resolveBuffColors = async (actions: Action[]): Promise<Action[]> => Promise.all(actions.map(async action => ({
61+
...action,
62+
statusesApplied: await Promise.all((action.statusesApplied ?? []).map(async status =>
63+
status.color === 'auto' && status.enabled !== false
64+
? { ...status, color: await automaticBuffColor(status.imageSrc) }
65+
: status,
66+
)),
67+
})))

src/lib/iconImages.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const loadableSource = (source: string): string => {
2+
// Proxy remote icons through Next's same-origin image optimizer. Besides
3+
// deterministic decoding, this keeps exported canvases origin-clean even
4+
// when a third-party image host does not emit CORS headers.
5+
if (/^https?:\/\//i.test(source)) {
6+
return `/_next/image?url=${encodeURIComponent(source)}&w=640&q=90`
7+
}
8+
return source
9+
}
10+
11+
export const loadRenderImages = async (sources: string[], signal?: AbortSignal): Promise<Map<string, HTMLImageElement>> => {
12+
const entries = await Promise.all(Array.from(new Set(sources)).map(async source => {
13+
if (signal?.aborted) throw new DOMException('Render superseded', 'AbortError')
14+
let pending = imageCache.get(source)
15+
if (!pending) {
16+
pending = decodeImage(source)
17+
imageCache.set(source, pending)
18+
pending.catch(() => imageCache.delete(source))
19+
if (imageCache.size > 256) imageCache.delete(imageCache.keys().next().value!)
20+
}
21+
const loaded = await pending
22+
if (signal?.aborted) throw new DOMException('Render superseded', 'AbortError')
23+
return [source, loaded] as const
24+
}))
25+
return new Map(entries)
26+
}
27+
28+
const imageCache = new Map<string, Promise<HTMLImageElement>>()
29+
30+
const decodeImage = async (source: string): Promise<HTMLImageElement> => {
31+
const loaded = new Image()
32+
loaded.crossOrigin = 'anonymous'
33+
const loadResult = new Promise<void>((resolve, reject) => {
34+
loaded.addEventListener('load', () => resolve(), { once: true })
35+
loaded.addEventListener('error', () => reject(new Error(`Unable to load image: ${source}`)), { once: true })
36+
})
37+
loaded.src = loadableSource(source)
38+
await loadResult
39+
try {
40+
await loaded.decode()
41+
} catch {
42+
// A completed load is drawable even where decode() is unsupported.
43+
}
44+
return loaded
45+
}

0 commit comments

Comments
 (0)