|
@@ -1,3 +1,5 @@
|
|
|
|
|
+import { hash_fnv1a32, random_mulberry32 } from '../utils/random';
|
|
|
|
|
+
|
|
|
/* Enhanced filament-colour rendering helpers (#1154).
|
|
/* Enhanced filament-colour rendering helpers (#1154).
|
|
|
*
|
|
*
|
|
|
* Pure (non-component) exports that drive `<FilamentSwatch>` and any caller
|
|
* Pure (non-component) exports that drive `<FilamentSwatch>` and any caller
|
|
@@ -37,6 +39,22 @@ export type FilamentEffect =
|
|
|
| 'tri-color'
|
|
| 'tri-color'
|
|
|
| 'multicolor';
|
|
| 'multicolor';
|
|
|
|
|
|
|
|
|
|
+/** Intended target swatch type for effect rendering. */
|
|
|
|
|
+export type SwatchType = 'table' | 'preview' | 'card' | 'bar' | 'groupheader';
|
|
|
|
|
+export type EffectLayer = string | string[];
|
|
|
|
|
+
|
|
|
|
|
+/** Presets for the different swatch types */
|
|
|
|
|
+export const SWATCH_TYPE_PRESETS: Readonly<Record<SwatchType, {
|
|
|
|
|
+ dotCount: number;
|
|
|
|
|
+ dotScale: number;
|
|
|
|
|
+}>> = {
|
|
|
|
|
+ table: { dotCount: 5, dotScale: 1 },
|
|
|
|
|
+ preview: { dotCount: 8, dotScale: 1.5 },
|
|
|
|
|
+ card: { dotCount: 40, dotScale: 2 },
|
|
|
|
|
+ bar: { dotCount: 20, dotScale: 2 },
|
|
|
|
|
+ groupheader: { dotCount: 80, dotScale: 2 },
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
/** Public list of all known effect/variant values, in display order. Shared
|
|
/** Public list of all known effect/variant values, in display order. Shared
|
|
|
* by the spool form's ColorSection dropdown and the colour-catalog editor
|
|
* by the spool form's ColorSection dropdown and the colour-catalog editor
|
|
|
* so the two stay in lockstep. Each value pairs with an i18n key under
|
|
* so the two stay in lockstep. Each value pairs with an i18n key under
|
|
@@ -76,7 +94,7 @@ export const FILAMENT_EFFECT_OPTIONS: ReadonlyArray<{
|
|
|
// follow-up reporter feedback). Per-layer sizing is supported by every
|
|
// follow-up reporter feedback). Per-layer sizing is supported by every
|
|
|
// modern browser via comma-separated ``background-size``.
|
|
// modern browser via comma-separated ``background-size``.
|
|
|
export const CHECKERBOARD_BG =
|
|
export const CHECKERBOARD_BG =
|
|
|
- 'repeating-conic-gradient(#cbcbcb 0% 25%, #f5f5f5 0% 50%)';
|
|
|
|
|
|
|
+ 'repeating-conic-gradient(#979797 0% 25%, #f5f5f5 0% 50%)';
|
|
|
export const CHECKERBOARD_TILE_SIZE = '12px 12px';
|
|
export const CHECKERBOARD_TILE_SIZE = '12px 12px';
|
|
|
|
|
|
|
|
/** Optional CSS overlay layer for variants that have a visual treatment.
|
|
/** Optional CSS overlay layer for variants that have a visual treatment.
|
|
@@ -84,48 +102,47 @@ export const CHECKERBOARD_TILE_SIZE = '12px 12px';
|
|
|
* an overlay, just sit in the data. `multicolor` is special: its visual
|
|
* an overlay, just sit in the data. `multicolor` is special: its visual
|
|
|
* effect is to switch the colour layer to a conic-gradient (see
|
|
* effect is to switch the colour layer to a conic-gradient (see
|
|
|
* `buildColorLayer`), not to add an overlay layer. */
|
|
* `buildColorLayer`), not to add an overlay layer. */
|
|
|
-export const EFFECT_OVERLAYS: Partial<Record<FilamentEffect, string>> = {
|
|
|
|
|
- // Sparkle: bright flecks scattered across the swatch. The original 4-dot
|
|
|
|
|
- // pattern was too subtle on card-sized swatches (#1154 follow-up); 13
|
|
|
|
|
- // dots in mixed sizes (1px / 1.5px / 2px) and opacities give depth and
|
|
|
|
|
- // make the variant clearly distinguishable from solid + multicolor.
|
|
|
|
|
- sparkle:
|
|
|
|
|
- 'radial-gradient(circle at 12% 18%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 28% 42%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 38% 78%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 52% 12%, rgba(255,255,255,0.80) 0 1px, transparent 1.5px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 58% 55%, rgba(255,255,255,1) 0 2px, transparent 2.5px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 68% 28%, rgba(255,255,255,0.75) 0 1px, transparent 1.5px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 75% 88%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 82% 48%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 88% 18%, rgba(255,255,255,0.80) 0 1px, transparent 1.5px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 92% 70%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 18% 62%, rgba(255,255,255,0.75) 0 1px, transparent 1.5px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 45% 32%, rgba(255,255,255,0.65) 0 0.8px, transparent 1.2px), ' +
|
|
|
|
|
- 'radial-gradient(circle at 65% 72%, rgba(255,255,255,0.65) 0 0.8px, transparent 1.2px)',
|
|
|
|
|
|
|
+export const EFFECT_OVERLAYS: Partial<
|
|
|
|
|
+ Record<FilamentEffect, (effectSeed?: number, effectSize?: SwatchType) => EffectLayer>
|
|
|
|
|
+> = {
|
|
|
|
|
+ // Sparkle: bright flecks — positions seeded from spool color+extracolors+subtype+effectType.
|
|
|
|
|
+ // to give identical spools the same sparkle pattern while different spools get different patterns.
|
|
|
|
|
+ sparkle: (spoolSeed = 0, effectSize = 'table') => {
|
|
|
|
|
+ const rand = random_mulberry32(spoolSeed);
|
|
|
|
|
+ const preset = SWATCH_TYPE_PRESETS[effectSize] ?? SWATCH_TYPE_PRESETS.table;
|
|
|
|
|
+ const sparks: string[] = [];
|
|
|
|
|
+ for (let i = 0; i < preset.dotCount; i++) {
|
|
|
|
|
+ const x = rand.intBetween(1, 99);
|
|
|
|
|
+ const y = rand.intBetween(1, 99);
|
|
|
|
|
+ const s = rand.floatBetween(1.0, preset.dotScale);
|
|
|
|
|
+ const a = rand.floatBetween(0.65, 1.0);
|
|
|
|
|
+ sparks.push(`radial-gradient(circle at ${x}% ${y}%, rgba(255,248,220,${a}) 0 ${s/2}px, transparent ${s}px)`);
|
|
|
|
|
+ }
|
|
|
|
|
+ return sparks;
|
|
|
|
|
+ },
|
|
|
// Wood: subtle horizontal banding to mimic grain.
|
|
// Wood: subtle horizontal banding to mimic grain.
|
|
|
- wood:
|
|
|
|
|
|
|
+ wood: () =>
|
|
|
'repeating-linear-gradient(90deg, ' +
|
|
'repeating-linear-gradient(90deg, ' +
|
|
|
'rgba(0,0,0,0.18) 0 1px, transparent 1px 6px, ' +
|
|
'rgba(0,0,0,0.18) 0 1px, transparent 1px 6px, ' +
|
|
|
'rgba(0,0,0,0.08) 6px 7px, transparent 7px 12px)',
|
|
'rgba(0,0,0,0.08) 6px 7px, transparent 7px 12px)',
|
|
|
// Marble: soft diagonal swirls.
|
|
// Marble: soft diagonal swirls.
|
|
|
- marble:
|
|
|
|
|
|
|
+ marble: () =>
|
|
|
'repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0 2px, transparent 2px 8px), ' +
|
|
'repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0 2px, transparent 2px 8px), ' +
|
|
|
'repeating-linear-gradient(45deg, rgba(0,0,0,0.10) 0 1px, transparent 1px 7px)',
|
|
'repeating-linear-gradient(45deg, rgba(0,0,0,0.10) 0 1px, transparent 1px 7px)',
|
|
|
// Glow: bright center fade — visual hint for glow-in-the-dark filaments.
|
|
// Glow: bright center fade — visual hint for glow-in-the-dark filaments.
|
|
|
- glow:
|
|
|
|
|
|
|
+ glow: () =>
|
|
|
'radial-gradient(circle at 50% 50%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 70%)',
|
|
'radial-gradient(circle at 50% 50%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 70%)',
|
|
|
// Matte: very subtle inset shadow to flatten the highlight.
|
|
// Matte: very subtle inset shadow to flatten the highlight.
|
|
|
- matte:
|
|
|
|
|
|
|
+ matte: () =>
|
|
|
'linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0) 50%, rgba(0,0,0,0.10) 100%)',
|
|
'linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0) 50%, rgba(0,0,0,0.10) 100%)',
|
|
|
// Silk / Galaxy: diagonal sheen to suggest the lustrous finish those
|
|
// Silk / Galaxy: diagonal sheen to suggest the lustrous finish those
|
|
|
// filaments have. Galaxy uses a slightly stronger highlight.
|
|
// filaments have. Galaxy uses a slightly stronger highlight.
|
|
|
- silk:
|
|
|
|
|
|
|
+ silk: () =>
|
|
|
'linear-gradient(110deg, rgba(255,255,255,0) 30%, rgba(255,255,255,0.30) 50%, rgba(255,255,255,0) 70%)',
|
|
'linear-gradient(110deg, rgba(255,255,255,0) 30%, rgba(255,255,255,0.30) 50%, rgba(255,255,255,0) 70%)',
|
|
|
- galaxy:
|
|
|
|
|
|
|
+ galaxy: () =>
|
|
|
'linear-gradient(110deg, rgba(255,255,255,0) 25%, rgba(255,255,255,0.40) 50%, rgba(255,255,255,0) 75%)',
|
|
'linear-gradient(110deg, rgba(255,255,255,0) 25%, rgba(255,255,255,0.40) 50%, rgba(255,255,255,0) 75%)',
|
|
|
// Metal: brushed-metal look via tight horizontal striations + soft sheen.
|
|
// Metal: brushed-metal look via tight horizontal striations + soft sheen.
|
|
|
- metal:
|
|
|
|
|
|
|
+ metal: () =>
|
|
|
'repeating-linear-gradient(90deg, rgba(255,255,255,0.10) 0 1px, transparent 1px 3px), ' +
|
|
'repeating-linear-gradient(90deg, rgba(255,255,255,0.10) 0 1px, transparent 1px 3px), ' +
|
|
|
'linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(0,0,0,0.18) 100%)',
|
|
'linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(0,0,0,0.18) 100%)',
|
|
|
};
|
|
};
|
|
@@ -174,7 +191,15 @@ export function buildColorLayer(
|
|
|
const subtypeLower = (subtype ?? '').toLowerCase();
|
|
const subtypeLower = (subtype ?? '').toLowerCase();
|
|
|
const effectLower = (effectType ?? '').toLowerCase();
|
|
const effectLower = (effectType ?? '').toLowerCase();
|
|
|
if (subtypeLower === 'multicolor' || effectLower === 'multicolor') {
|
|
if (subtypeLower === 'multicolor' || effectLower === 'multicolor') {
|
|
|
- return `conic-gradient(from 0deg, ${allStops.join(', ')}, ${allStops[0]})`;
|
|
|
|
|
|
|
+ const n = allStops.length;
|
|
|
|
|
+ const segments = allStops
|
|
|
|
|
+ .map((c, i) => {
|
|
|
|
|
+ const start = ((i / n) * 360).toFixed(3);
|
|
|
|
|
+ const end = (((i + 1) / n) * 360).toFixed(3);
|
|
|
|
|
+ return `${c} ${start}deg ${end}deg`;
|
|
|
|
|
+ })
|
|
|
|
|
+ .join(', ');
|
|
|
|
|
+ return `conic-gradient(from 0deg, ${segments})`;
|
|
|
}
|
|
}
|
|
|
if (effectLower === 'dual-color' || effectLower === 'tri-color') {
|
|
if (effectLower === 'dual-color' || effectLower === 'tri-color') {
|
|
|
// Equal-width hard-split bars: each stop occupies its own contiguous
|
|
// Equal-width hard-split bars: each stop occupies its own contiguous
|
|
@@ -194,6 +219,16 @@ export function buildColorLayer(
|
|
|
return `linear-gradient(135deg, ${allStops.join(', ')})`;
|
|
return `linear-gradient(135deg, ${allStops.join(', ')})`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** Resolve the CSS overlay string for an effect key. */
|
|
|
|
|
+export function resolveEffectOverlay(
|
|
|
|
|
+ effectKey: string,
|
|
|
|
|
+ effectSize: SwatchType,
|
|
|
|
|
+ effectSeed?: number,
|
|
|
|
|
+): EffectLayer | null {
|
|
|
|
|
+ const fn = EFFECT_OVERLAYS[effectKey as FilamentEffect];
|
|
|
|
|
+ return fn ? fn(effectSeed, effectSize) : null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/** Public helper: produce a CSS background-image value (list of layered
|
|
/** Public helper: produce a CSS background-image value (list of layered
|
|
|
* <image>s) for a filament, for callers that want to paint a banner or
|
|
* <image>s) for a filament, for callers that want to paint a banner or
|
|
|
* large area instead of using the swatch element. Returns a
|
|
* large area instead of using the swatch element. Returns a
|
|
@@ -203,6 +238,7 @@ export function buildColorLayer(
|
|
|
* card-sized banner only shows 4 huge checker cells.
|
|
* card-sized banner only shows 4 huge checker cells.
|
|
|
*/
|
|
*/
|
|
|
export function buildFilamentBackground(opts: {
|
|
export function buildFilamentBackground(opts: {
|
|
|
|
|
+ effectSize: SwatchType;
|
|
|
rgba?: string | null;
|
|
rgba?: string | null;
|
|
|
extraColors?: string | null;
|
|
extraColors?: string | null;
|
|
|
effectType?: FilamentEffect | string | null;
|
|
effectType?: FilamentEffect | string | null;
|
|
@@ -210,18 +246,22 @@ export function buildFilamentBackground(opts: {
|
|
|
}): { backgroundImage: string; backgroundSize: string } {
|
|
}): { backgroundImage: string; backgroundSize: string } {
|
|
|
const stops = parseStops(opts.extraColors);
|
|
const stops = parseStops(opts.extraColors);
|
|
|
const colorLayer = buildColorLayer(opts.rgba, stops, opts.subtype, opts.effectType);
|
|
const colorLayer = buildColorLayer(opts.rgba, stops, opts.subtype, opts.effectType);
|
|
|
- const effectKey =
|
|
|
|
|
- typeof opts.effectType === 'string' && opts.effectType in EFFECT_OVERLAYS
|
|
|
|
|
- ? (opts.effectType as FilamentEffect)
|
|
|
|
|
|
|
+ const effectSeed = hash_fnv1a32(opts.rgba, opts.extraColors, opts.subtype, opts.effectType);
|
|
|
|
|
+ const effectLayer =
|
|
|
|
|
+ typeof opts.effectType === 'string'
|
|
|
|
|
+ ? resolveEffectOverlay(opts.effectType, opts.effectSize, effectSeed)
|
|
|
: null;
|
|
: null;
|
|
|
- const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
|
|
|
|
|
-
|
|
|
|
|
// Layer order (top → bottom): effect overlay → colour layer → checkerboard.
|
|
// Layer order (top → bottom): effect overlay → colour layer → checkerboard.
|
|
|
// Per-layer background-size: 'cover' on the painted layers (so they fill
|
|
// Per-layer background-size: 'cover' on the painted layers (so they fill
|
|
|
// the element) and the fixed tile size on the checkerboard so the cell
|
|
// the element) and the fixed tile size on the checkerboard so the cell
|
|
|
// count scales with the element rather than the element scaling the cells.
|
|
// count scales with the element rather than the element scaling the cells.
|
|
|
const layers: { image: string; size: string }[] = [];
|
|
const layers: { image: string; size: string }[] = [];
|
|
|
- if (effectLayer) layers.push({ image: effectLayer, size: 'cover' });
|
|
|
|
|
|
|
+ if (effectLayer) {
|
|
|
|
|
+ const effectImages = Array.isArray(effectLayer) ? effectLayer : [effectLayer];
|
|
|
|
|
+ effectImages.forEach((image) => {
|
|
|
|
|
+ layers.push({ image, size: 'cover' });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
layers.push({ image: colorLayer, size: 'cover' });
|
|
layers.push({ image: colorLayer, size: 'cover' });
|
|
|
layers.push({ image: CHECKERBOARD_BG, size: CHECKERBOARD_TILE_SIZE });
|
|
layers.push({ image: CHECKERBOARD_BG, size: CHECKERBOARD_TILE_SIZE });
|
|
|
|
|
|