feat: add controls from other projects
This commit is contained in:
Родитель
3802930b3a
Коммит
b84f1ff024
|
@ -0,0 +1,448 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import { Params, Scheme } from '../interfaces'
|
||||
const chroma = require('chroma-js')
|
||||
const hsluv = require('hsluv')
|
||||
|
||||
const lightTextLuminance = 95
|
||||
const darkTextLuminance = 20
|
||||
const lightScaleLuminance = 70
|
||||
const darkScaleLuminance = 70
|
||||
const maxSaturation = 100
|
||||
const maxLightLuminanceOffset = 5
|
||||
const maxDarkLuminanceOffset = 10
|
||||
const maxBackgroundChroma = 4 // absolute
|
||||
const lightScaleBackgroundLuminanceShift = 10
|
||||
const darkScaleBackgroundLuminanceShift = 3
|
||||
const analogousRange = 60
|
||||
const complementaryRange = 60
|
||||
const nominalSaturation = 90
|
||||
const nominalLighterShift = 15
|
||||
const nominalDarkerShift = 30
|
||||
const boldSaturationShift = 10
|
||||
const mutedSaturationShift = 55
|
||||
const minNominalSaturation = 10
|
||||
const minNominalLuminance = 50
|
||||
const linearExponent = 1
|
||||
const polynomialExponent = 1.5
|
||||
const lowContrastBackgroundShift = 20
|
||||
const darkestGrey = 20
|
||||
const lightestGrey = 90
|
||||
const offsetBackgroundLuminanceShift = 1
|
||||
|
||||
export interface NominalHueSequence {
|
||||
bold: [number, number, number][]
|
||||
std: [number, number, number][]
|
||||
muted: [number, number, number][]
|
||||
hues: number[]
|
||||
size: number
|
||||
}
|
||||
|
||||
export function polynomial_scale(
|
||||
exp: number,
|
||||
start: number,
|
||||
end: number,
|
||||
size: number,
|
||||
): number[] {
|
||||
const base = 1 / (size - 1)
|
||||
const interval = end - start
|
||||
const result: number[] = []
|
||||
for (let i = 0; i < size; i++) {
|
||||
result.push(start + interval * (i * base) ** exp)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export type HSLVector = [number, number, number]
|
||||
|
||||
export function polynomial_hsl_scale(
|
||||
exp: number,
|
||||
[sh, ss, sl]: HSLVector,
|
||||
[eh, es, el]: HSLVector,
|
||||
size: number,
|
||||
): HSLVector[] {
|
||||
const hues = polynomial_scale(exp, sh, eh, size)
|
||||
const saturations = polynomial_scale(exp, ss, es, size)
|
||||
const luminances = polynomial_scale(exp, sl, el, size)
|
||||
const hexvalues: HSLVector[] = []
|
||||
for (let i = 0; i < size; i++) {
|
||||
hexvalues.push([hues[i], saturations[i], luminances[i]])
|
||||
}
|
||||
return hexvalues
|
||||
}
|
||||
|
||||
// Based on a maximum hue shift of 100 (from the slider)
|
||||
function getOffsetHue([h, s, l]: HSLVector, hueShift: number) {
|
||||
if (hueShift >= 25 && hueShift <= 75) {
|
||||
// analogous range
|
||||
const delta = (analogousRange * (hueShift - 50)) / 25
|
||||
return (h + delta) % 360
|
||||
} else if (hueShift > 75) {
|
||||
// clockwise complementary range
|
||||
const delta = 180 - (complementaryRange * (100 - hueShift)) / 25
|
||||
return (h + delta) % 360
|
||||
} else {
|
||||
// anticlockwise complementary range
|
||||
const delta = 180 + (complementaryRange * hueShift) / 25
|
||||
return (h + delta) % 360
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundSaturationaAndLuminance(
|
||||
hue: number,
|
||||
backgroundLevel: number,
|
||||
light: boolean,
|
||||
): [number, number] {
|
||||
function normalizeSaturation(h: number, l: number) {
|
||||
let satGivingMaxChroma = 100
|
||||
let c = chroma(hsluv.hsluvToHex([hue, 100, l])).hcl()[1]
|
||||
while (c > maxBackgroundChroma && satGivingMaxChroma >= 0) {
|
||||
satGivingMaxChroma -= 1
|
||||
c = chroma(hsluv.hsluvToHex([hue, satGivingMaxChroma, l])).hcl()[1]
|
||||
}
|
||||
return satGivingMaxChroma
|
||||
}
|
||||
if (light) {
|
||||
if (backgroundLevel < 50) {
|
||||
const l = 100 - maxLightLuminanceOffset
|
||||
return [(backgroundLevel * normalizeSaturation(hue, l)) / 50, l]
|
||||
} else {
|
||||
const l =
|
||||
100 - maxLightLuminanceOffset * (1 - (backgroundLevel - 50) / 50)
|
||||
return [normalizeSaturation(hue, l), l]
|
||||
}
|
||||
} else {
|
||||
if (backgroundLevel < 50) {
|
||||
const l = maxDarkLuminanceOffset
|
||||
return [(backgroundLevel * normalizeSaturation(hue, l)) / 50, l]
|
||||
} else {
|
||||
const l = maxDarkLuminanceOffset * (1 - (backgroundLevel - 50) / 50)
|
||||
return [normalizeSaturation(hue, l), l]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ColorMaker {
|
||||
public readonly textLuminance: number
|
||||
public readonly scaleLuminance: number
|
||||
public readonly backgroundHsl: [number, number, number]
|
||||
public readonly offsetbackgroundHsl: [number, number, number]
|
||||
public readonly foregroundHsl: [number, number, number]
|
||||
public readonly faintAnnotationHsl: [number, number, number]
|
||||
public readonly lowContrastAnnotationHsl: [number, number, number]
|
||||
public readonly midContrastAnnotationHsl: [number, number, number]
|
||||
public readonly highContrastAnnotationHsl: [number, number, number]
|
||||
public readonly greyLuminance: number
|
||||
|
||||
private cachedNominalSequence: NominalHueSequence | undefined
|
||||
|
||||
public constructor(
|
||||
public readonly accentHsl: [number, number, number],
|
||||
public readonly backgroundLevel: number,
|
||||
public readonly backgroundHueShift: number,
|
||||
public readonly nominalHueStep: number,
|
||||
public readonly light: boolean,
|
||||
) {
|
||||
this.textLuminance = light ? darkTextLuminance : lightTextLuminance
|
||||
this.scaleLuminance = light ? darkScaleLuminance : lightScaleLuminance
|
||||
|
||||
const h = getOffsetHue(this.accentHsl, this.backgroundHueShift)
|
||||
const [s, l] = getBackgroundSaturationaAndLuminance(
|
||||
h,
|
||||
this.backgroundLevel,
|
||||
this.light,
|
||||
)
|
||||
this.backgroundHsl = [h, s, l]
|
||||
const offsetBackgroundLuminance = light
|
||||
? Math.min(100, l + offsetBackgroundLuminanceShift)
|
||||
: Math.max(0, l - offsetBackgroundLuminanceShift)
|
||||
this.offsetbackgroundHsl = [h, s, offsetBackgroundLuminance]
|
||||
this.foregroundHsl = [0, 0, this.textLuminance]
|
||||
|
||||
this.greyLuminance = this.light
|
||||
? this.backgroundHsl[2] - darkScaleBackgroundLuminanceShift
|
||||
: this.backgroundHsl[2] + lightScaleBackgroundLuminanceShift
|
||||
const mutedGreyLuminance = this.light
|
||||
? this.backgroundHsl[2] - lowContrastBackgroundShift
|
||||
: this.backgroundHsl[2] + lowContrastBackgroundShift
|
||||
|
||||
const boldGreyLuminance = this.light ? darkestGrey : lightestGrey
|
||||
const greys = this.explicitGrey(3, mutedGreyLuminance, boldGreyLuminance)
|
||||
this.lowContrastAnnotationHsl = greys[0]
|
||||
this.midContrastAnnotationHsl = greys[1]
|
||||
this.highContrastAnnotationHsl = greys[2]
|
||||
// halfway to the background from the darkest/lightest
|
||||
const faintLuminance = this.light
|
||||
? (100 - lightestGrey) / 2 + lightestGrey
|
||||
: darkestGrey / 2
|
||||
this.faintAnnotationHsl = [this.accentHsl[0], 0, faintLuminance]
|
||||
}
|
||||
|
||||
public grey(size: number): HSLVector[] {
|
||||
const boldGreyLuminance = this.light ? darkestGrey : lightestGrey
|
||||
return this.explicitGrey(size, this.greyLuminance, boldGreyLuminance)
|
||||
}
|
||||
|
||||
public nominal(size: number): NominalHueSequence {
|
||||
if (
|
||||
!this.cachedNominalSequence ||
|
||||
this.cachedNominalSequence.size !== size
|
||||
) {
|
||||
const {
|
||||
nominalBold,
|
||||
nominal,
|
||||
nominalMuted,
|
||||
nominalHues,
|
||||
} = this.getNominalHueSequences(size)
|
||||
this.cachedNominalSequence = {
|
||||
bold: nominalBold,
|
||||
std: nominal,
|
||||
muted: nominalMuted,
|
||||
hues: nominalHues,
|
||||
size: nominal.length,
|
||||
}
|
||||
}
|
||||
return this.cachedNominalSequence
|
||||
}
|
||||
|
||||
private explicitGrey(
|
||||
size: number,
|
||||
startLuminance: number,
|
||||
endLuminance: number,
|
||||
) {
|
||||
const greyFrom: HSLVector = [this.accentHsl[0], 0, startLuminance]
|
||||
const greyTo: HSLVector = [this.accentHsl[0], 0, endLuminance]
|
||||
return polynomial_hsl_scale(linearExponent, greyFrom, greyTo, size)
|
||||
}
|
||||
|
||||
private getAccentsAndComplements(
|
||||
size: number,
|
||||
offset: number,
|
||||
): {
|
||||
greyAccent: HSLVector
|
||||
greyComplement: HSLVector
|
||||
maxSaturationAccent: HSLVector
|
||||
maxSaturationComplement: HSLVector
|
||||
} {
|
||||
const { hues: nominalHues } = this.nominal(size)
|
||||
|
||||
const accentHue = nominalHues[offset % nominalHues.length]
|
||||
|
||||
const colorLuminance = this.accentHsl[2]
|
||||
const greyLuminance = this.greyLuminance
|
||||
const complementHue = (accentHue + 90) % 360
|
||||
const greyAccent: HSLVector = [accentHue, 0, greyLuminance]
|
||||
const greyComplement: HSLVector = [complementHue, 0, greyLuminance]
|
||||
const maxSaturationAccent: HSLVector = [
|
||||
accentHue,
|
||||
maxSaturation,
|
||||
colorLuminance,
|
||||
]
|
||||
const maxSaturationComplement: HSLVector = [
|
||||
complementHue,
|
||||
maxSaturation,
|
||||
colorLuminance,
|
||||
]
|
||||
|
||||
return {
|
||||
greyAccent,
|
||||
greyComplement,
|
||||
maxSaturationAccent,
|
||||
maxSaturationComplement,
|
||||
}
|
||||
}
|
||||
|
||||
public sequential(size: number, offset = 0): HSLVector[] {
|
||||
const { greyAccent, maxSaturationAccent } = this.getAccentsAndComplements(
|
||||
size,
|
||||
offset,
|
||||
)
|
||||
return polynomial_hsl_scale(
|
||||
linearExponent,
|
||||
greyAccent,
|
||||
maxSaturationAccent,
|
||||
size,
|
||||
)
|
||||
}
|
||||
|
||||
public sequentialComplement(size: number, offset = 0): HSLVector[] {
|
||||
const {
|
||||
greyAccent,
|
||||
maxSaturationComplement,
|
||||
} = this.getAccentsAndComplements(size, offset)
|
||||
return polynomial_hsl_scale(
|
||||
linearExponent,
|
||||
greyAccent,
|
||||
maxSaturationComplement,
|
||||
size,
|
||||
)
|
||||
}
|
||||
|
||||
public diverging(size: number, offset = 0): HSLVector[] {
|
||||
const {
|
||||
greyAccent,
|
||||
greyComplement,
|
||||
maxSaturationAccent,
|
||||
maxSaturationComplement,
|
||||
} = this.getAccentsAndComplements(size, offset)
|
||||
|
||||
const half = Math.round(size / 2)
|
||||
const cut = size % 2 === 0 ? half + 1 : half
|
||||
|
||||
let diverging = polynomial_hsl_scale(
|
||||
polynomialExponent,
|
||||
greyComplement,
|
||||
maxSaturationComplement,
|
||||
cut,
|
||||
)
|
||||
.slice(1)
|
||||
.reverse()
|
||||
|
||||
let toAdd = polynomial_hsl_scale(
|
||||
polynomialExponent,
|
||||
greyAccent,
|
||||
maxSaturationAccent,
|
||||
cut,
|
||||
)
|
||||
if (size % 2 === 0) {
|
||||
toAdd = toAdd.slice(1, cut)
|
||||
}
|
||||
diverging = diverging.concat(toAdd)
|
||||
return diverging
|
||||
}
|
||||
|
||||
private getNominalHueSequences(size: number) {
|
||||
const nominal: HSLVector[] = []
|
||||
const nominalBold: HSLVector[] = []
|
||||
const nominalMuted: HSLVector[] = []
|
||||
const nominalHues: number[] = []
|
||||
let hues: number[] = []
|
||||
const h = this.accentHsl[0]
|
||||
|
||||
const initialHues =
|
||||
this.nominalHueStep <= 10
|
||||
? 13 - this.nominalHueStep
|
||||
: this.nominalHueStep - 8
|
||||
|
||||
const hueDirection = this.nominalHueStep <= 10 ? -1 : 1
|
||||
|
||||
let hueStep = 360.0 / initialHues
|
||||
|
||||
for (let i = 0; i < initialHues; i += 1) {
|
||||
hues.push((h + i * hueDirection * hueStep) % 360)
|
||||
}
|
||||
|
||||
while (hues.length < size) {
|
||||
hueStep = hueStep / 2
|
||||
const newHues: number[] = []
|
||||
for (const existingHue of hues) {
|
||||
newHues.push((existingHue + hueDirection * hueStep) % 360)
|
||||
if (hues.length + newHues.length === size) {
|
||||
break
|
||||
}
|
||||
}
|
||||
hues = hues.concat(newHues)
|
||||
}
|
||||
|
||||
// trim the core hues to match requested size
|
||||
// this acounts for initialHues calculation
|
||||
// which can result in minimum counts higher than requested
|
||||
hues = hues.slice(0, size)
|
||||
|
||||
let baseSaturation = nominalSaturation
|
||||
let baseLuminance = this.scaleLuminance
|
||||
|
||||
const addHue = (hue: number) => {
|
||||
nominal.push([hue, baseSaturation, baseLuminance])
|
||||
nominalBold.push([
|
||||
hue,
|
||||
0.5 * (nominalSaturation + baseSaturation) + boldSaturationShift,
|
||||
0.5 * (this.scaleLuminance + baseLuminance) - nominalDarkerShift,
|
||||
])
|
||||
nominalMuted.push([
|
||||
hue,
|
||||
0.5 * (nominalSaturation + baseSaturation) - mutedSaturationShift,
|
||||
0.5 * (this.scaleLuminance + baseLuminance) + nominalLighterShift,
|
||||
])
|
||||
nominalHues.push(hue)
|
||||
if (nominal.length % 3 === 0) {
|
||||
baseSaturation = Math.max(baseSaturation - 1, minNominalSaturation)
|
||||
}
|
||||
if (nominal.length % 6 === 0) {
|
||||
baseLuminance = Math.max(baseLuminance - 1, minNominalLuminance)
|
||||
}
|
||||
}
|
||||
|
||||
for (const finalHue of hues) {
|
||||
addHue(finalHue)
|
||||
}
|
||||
|
||||
return {
|
||||
nominal,
|
||||
nominalBold,
|
||||
nominalMuted,
|
||||
nominalHues,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hsluv_to_hex(values: HSLVector[]): string[] {
|
||||
return values.map(c => hsluv.hsluvToHex(c))
|
||||
}
|
||||
|
||||
// TODO: this should probably throw errors if out-of-bounds values are submitted OR, wrap around the geometry if that's always valid
|
||||
export function getScheme(
|
||||
params: Params,
|
||||
nominalItemCount: number,
|
||||
sequentialItemCount: number,
|
||||
light: boolean,
|
||||
): Scheme {
|
||||
const {
|
||||
accentHue,
|
||||
accentSaturation,
|
||||
accentLuminance,
|
||||
backgroundLevel,
|
||||
backgroundHueShift,
|
||||
nominalHueStep,
|
||||
} = params
|
||||
|
||||
const colorMaker = new ColorMaker(
|
||||
[accentHue, accentSaturation, accentLuminance],
|
||||
backgroundLevel,
|
||||
backgroundHueShift,
|
||||
nominalHueStep,
|
||||
light,
|
||||
)
|
||||
|
||||
const nominalSequences = colorMaker.nominal(nominalItemCount)
|
||||
|
||||
return {
|
||||
background: hsluv.hsluvToHex(colorMaker.backgroundHsl),
|
||||
offsetBackground: hsluv.hsluvToHex(colorMaker.offsetbackgroundHsl),
|
||||
foreground: hsluv.hsluvToHex(colorMaker.foregroundHsl),
|
||||
accent: hsluv.hsluvToHex(colorMaker.accentHsl),
|
||||
lowContrastAnnotation: hsluv.hsluvToHex(
|
||||
colorMaker.lowContrastAnnotationHsl,
|
||||
),
|
||||
midContrastAnnotation: hsluv.hsluvToHex(
|
||||
colorMaker.midContrastAnnotationHsl,
|
||||
),
|
||||
highContrastAnnotation: hsluv.hsluvToHex(
|
||||
colorMaker.highContrastAnnotationHsl,
|
||||
),
|
||||
faintAnnotation: hsluv.hsluvToHex(colorMaker.faintAnnotationHsl),
|
||||
sequential: hsluv_to_hex(colorMaker.sequential(sequentialItemCount, 0)),
|
||||
sequential2: hsluv_to_hex(colorMaker.sequential(sequentialItemCount, 1)),
|
||||
diverging: hsluv_to_hex(colorMaker.diverging(sequentialItemCount, 0)),
|
||||
diverging2: hsluv_to_hex(colorMaker.diverging(sequentialItemCount, 1)),
|
||||
nominalBold: hsluv_to_hex(nominalSequences.bold),
|
||||
nominal: hsluv_to_hex(nominalSequences.std),
|
||||
nominalMuted: hsluv_to_hex(nominalSequences.muted),
|
||||
greys: hsluv_to_hex(colorMaker.grey(sequentialItemCount)),
|
||||
warning: '#ff8c00',
|
||||
error: '#d13438',
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
|
||||
import { Color } from '../Color'
|
||||
import { Scheme } from '../interfaces'
|
||||
|
||||
/**
|
||||
* Extracts a thematic Color using its scheme "path".
|
||||
* @param scheme
|
||||
* @param path
|
||||
*/
|
||||
export function getNamedSchemeColor(scheme: Scheme, path?: string): Color {
|
||||
if (!path || path === 'none') {
|
||||
return new Color('none')
|
||||
}
|
||||
const indexed = indexedTest(path)
|
||||
if (indexed) {
|
||||
return new Color((scheme as any)[indexed.name][indexed.index])
|
||||
}
|
||||
return new Color((scheme as any)[path])
|
||||
}
|
||||
|
||||
function indexedTest(path: string) {
|
||||
const indexedName = path.match(/(\w+)\[/)
|
||||
const indexedIndex = path.match(/\[(\d{1,2})\]/)
|
||||
if (indexedName && indexedIndex) {
|
||||
return {
|
||||
name: indexedName[1],
|
||||
index: +indexedIndex[1],
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
export * from './HsluvColorLogic'
|
|
@ -0,0 +1,7 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
export { getScheme } from './getScheme'
|
||||
export * from './getNamedSchemeColor'
|
||||
export * from './isNominal'
|
|
@ -0,0 +1,7 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
export function isNominal(name?: string): boolean {
|
||||
return name === 'nominal' || name === 'nominalBold' || name === 'nominalMuted'
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import { scaleLinear } from 'd3-scale'
|
||||
import React, { useMemo } from 'react'
|
||||
import { ChipsProps } from './types'
|
||||
|
||||
export const ColorChips: React.FC<ChipsProps> = ({
|
||||
scale,
|
||||
width = 200,
|
||||
height = 10,
|
||||
maxItems = 10,
|
||||
}) => {
|
||||
const blocks = useMemo(() => {
|
||||
if (height <= 1) {
|
||||
return []
|
||||
}
|
||||
const r = height / 2 - 1
|
||||
const x = scaleLinear()
|
||||
.domain([0, maxItems - 1])
|
||||
.range([r, width - r])
|
||||
return scale
|
||||
.toArray(maxItems)
|
||||
.map((color, i) => (
|
||||
<circle
|
||||
key={`color-chips-${color}-${i}`}
|
||||
fill={color}
|
||||
stroke={'none'}
|
||||
cx={x(i)}
|
||||
cy={r}
|
||||
r={r}
|
||||
height={height}
|
||||
/>
|
||||
))
|
||||
}, [scale, width, height, maxItems])
|
||||
return (
|
||||
<svg width={width} height={height}>
|
||||
{blocks}
|
||||
</svg>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import React, { useMemo } from 'react'
|
||||
import { ChipsProps } from './types'
|
||||
|
||||
export const ContinuousBand: React.FC<ChipsProps> = ({
|
||||
scale,
|
||||
width,
|
||||
height,
|
||||
}) => {
|
||||
const blocks = useMemo(() => {
|
||||
return scale
|
||||
.toArray(width)
|
||||
.map((color, i) => (
|
||||
<rect
|
||||
key={`continuous-band-${color}-${i}`}
|
||||
fill={color}
|
||||
stroke={'none'}
|
||||
x={i}
|
||||
width={2}
|
||||
height={height}
|
||||
/>
|
||||
))
|
||||
}, [scale, width, height])
|
||||
return (
|
||||
<svg width={width} height={height}>
|
||||
{blocks}
|
||||
</svg>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import { Dropdown } from '@fluentui/react'
|
||||
import React, { useCallback, useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { ScaleDropdownItem } from './ScaleDropdownItem'
|
||||
import {
|
||||
usePaletteWidth,
|
||||
usePaletteHeight,
|
||||
useItemStyle,
|
||||
useThematicScaleOptions,
|
||||
} from './hooks/theme'
|
||||
import { useSafeDimensions } from './hooks/useSafeDimensions'
|
||||
import { ScaleDropdownProps } from './types'
|
||||
|
||||
/**
|
||||
* Represents a Fluent dropdown of Thematic scale options.
|
||||
* The scale names can be accompanied by a visual rendering of the scale colors.
|
||||
* This bascially extends Dropdown, overriding the options and item rendering.
|
||||
* TODO: move to @thematic/fluent
|
||||
*/
|
||||
export const ScaleDropdown: React.FC<ScaleDropdownProps> = props => {
|
||||
const ref = useRef(null)
|
||||
const { width, height } = useSafeDimensions(ref)
|
||||
const paletteWidth = usePaletteWidth(width)
|
||||
const paletteHeight = usePaletteHeight(height, props.label)
|
||||
const itemStyle = useItemStyle(width)
|
||||
const options = useThematicScaleOptions()
|
||||
|
||||
const handleRenderTitle = useCallback(
|
||||
([option]) => {
|
||||
return (
|
||||
<ScaleDropdownItem
|
||||
paletteWidth={paletteWidth}
|
||||
paletteHeight={paletteHeight}
|
||||
option={option}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[paletteWidth, paletteHeight],
|
||||
)
|
||||
|
||||
const handleRenderOption = useCallback(
|
||||
option => {
|
||||
return (
|
||||
<ScaleDropdownItem
|
||||
key={`scale-dropdown-item-${option.key}`}
|
||||
paletteWidth={paletteWidth}
|
||||
paletteHeight={paletteHeight}
|
||||
option={option}
|
||||
style={itemStyle}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[paletteWidth, paletteHeight, itemStyle],
|
||||
)
|
||||
|
||||
return (
|
||||
<Container ref={ref}>
|
||||
<Dropdown
|
||||
{...props}
|
||||
options={options}
|
||||
onRenderTitle={handleRenderTitle}
|
||||
onRenderOption={handleRenderOption}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div``
|
|
@ -0,0 +1,39 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { useSafeCollapseDimensions } from './hooks/useSafeDimensions'
|
||||
import { ScaleDropdownItemProps } from './types'
|
||||
import { selectColorPalette, useScale } from './util'
|
||||
|
||||
export const ScaleDropdownItem: React.FC<ScaleDropdownItemProps> = ({
|
||||
option,
|
||||
paletteWidth,
|
||||
paletteHeight,
|
||||
style,
|
||||
}) => {
|
||||
const { key } = option
|
||||
const Palette = selectColorPalette(key)
|
||||
const [width, height] = useSafeCollapseDimensions(paletteWidth, paletteHeight)
|
||||
const scale = useScale(option.key, paletteWidth)
|
||||
return (
|
||||
<Container style={style}>
|
||||
<Label>{option.text}</Label>
|
||||
<Palette scale={scale} width={width} height={height} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-width: 10;
|
||||
`
|
||||
|
||||
const Label = styled.div`
|
||||
width: 74px;
|
||||
`
|
|
@ -0,0 +1,65 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import { IDropdownOption } from '@fluentui/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useThematic } from '@thematic/react'
|
||||
|
||||
const ITEM_LEFT_PADDING = 8 // defined default in fluent dropdown
|
||||
const CARET_PADDING = 30 // defined default in fluent dropdown
|
||||
const TEXT_WIDTH = 80 // TODO: adjust this based on font size/max measured
|
||||
const LABEL_HEIGHT = 29 // defined default in fluent dropdown
|
||||
|
||||
// we may want this to be an optional prop once extracted
|
||||
// visually we'll keep it lowercase in this app for visual consistency
|
||||
const TITLE_CASE = false
|
||||
|
||||
export function usePaletteWidth(width: number): number {
|
||||
// subtract space for the caret, left pad, and text
|
||||
return width - CARET_PADDING - ITEM_LEFT_PADDING - TEXT_WIDTH
|
||||
}
|
||||
|
||||
export function usePaletteHeight(height: number, label?: string): number {
|
||||
// the measured component dimensions will include the label if present
|
||||
const root = label ? height - LABEL_HEIGHT : height
|
||||
return root / 2
|
||||
}
|
||||
/**
|
||||
* This provides unique style overrides for the dropdown items,
|
||||
* NOT the title. The paddings here are to align the item
|
||||
* contents visually with the title, so it has a seamless
|
||||
* appearance whether expanded or not.
|
||||
*/
|
||||
export function useItemStyle(width: number): React.CSSProperties {
|
||||
return useMemo(
|
||||
() => ({
|
||||
width: width - CARET_PADDING,
|
||||
paddingLeft: ITEM_LEFT_PADDING,
|
||||
paddingRight: CARET_PADDING,
|
||||
}),
|
||||
[width],
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: it would be helpful to provide a filter function so scales can be
|
||||
// constrained to categorical or sequential when data is string versus numeric
|
||||
export function useThematicScaleOptions(): IDropdownOption[] {
|
||||
const theme = useThematic()
|
||||
return useMemo(() => {
|
||||
const keys = Object.keys(theme.scales())
|
||||
return keys.map(key => {
|
||||
// pretty print the scale names
|
||||
// (1) uppercase first letter
|
||||
// (2) use +/- for bold/muted
|
||||
const text = key
|
||||
.replace(/^(\w{1})/, c => (TITLE_CASE ? c.toLocaleUpperCase() : c))
|
||||
.replace('Bold', '+')
|
||||
.replace('Muted', '-')
|
||||
return {
|
||||
key,
|
||||
text,
|
||||
}
|
||||
})
|
||||
}, [theme])
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import { Dimensions, useDimensions } from '@essex-js-toolkit/hooks'
|
||||
import React from 'react'
|
||||
|
||||
const DEFAULT_WIDTH = 200
|
||||
const DEFAULT_HEIGHT = 32
|
||||
export function useSafeDimensions(
|
||||
ref: React.RefObject<HTMLDivElement>,
|
||||
): Dimensions {
|
||||
const dimensions = useDimensions(ref)
|
||||
return (
|
||||
dimensions || {
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a non-zero width/height, because collapsible panels can force invalid color arrays
|
||||
* @param width
|
||||
* @param height
|
||||
*/
|
||||
export function useSafeCollapseDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
): [number, number] {
|
||||
return [width <= 0 ? 1 : width, height <= 0 ? 1 : height]
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
export * from './ScaleDropdown'
|
|
@ -0,0 +1,25 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import { IDropdownProps } from '@fluentui/react'
|
||||
import {
|
||||
ContinuousColorScaleFunction,
|
||||
NominalColorScaleFunction,
|
||||
} from '@thematic/core'
|
||||
|
||||
export type ScaleDropdownProps = Omit<IDropdownProps, 'options'>
|
||||
|
||||
export interface ScaleDropdownItemProps {
|
||||
option: any
|
||||
paletteWidth: number
|
||||
paletteHeight: number
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export interface ChipsProps {
|
||||
scale: ContinuousColorScaleFunction | NominalColorScaleFunction
|
||||
width?: number
|
||||
height?: number
|
||||
maxItems?: number
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { ColorChips } from './ColorChips'
|
||||
import { ContinuousBand } from './ContinuousBand'
|
||||
import { ChipsProps } from './types'
|
||||
import {
|
||||
Theme,
|
||||
NominalColorScaleFunction,
|
||||
ContinuousColorScaleFunction,
|
||||
} from '@thematic/core'
|
||||
import { useThematic } from '@thematic/react'
|
||||
|
||||
export function useScale(
|
||||
key: string,
|
||||
width: number,
|
||||
): NominalColorScaleFunction | ContinuousColorScaleFunction {
|
||||
const theme = useThematic()
|
||||
const scale = useMemo(() => chooseScale(theme, key, width), [
|
||||
theme,
|
||||
key,
|
||||
width,
|
||||
])
|
||||
return scale
|
||||
}
|
||||
|
||||
export function chooseScale(
|
||||
theme: Theme,
|
||||
key: string,
|
||||
width: number,
|
||||
): NominalColorScaleFunction | ContinuousColorScaleFunction {
|
||||
const scales = theme.scales()
|
||||
const domain = [0, width]
|
||||
switch (key) {
|
||||
case 'sequential':
|
||||
return scales.sequential(domain)
|
||||
case 'sequential2':
|
||||
return scales.sequential2(domain)
|
||||
case 'diverging':
|
||||
return scales.diverging(domain)
|
||||
case 'diverging2':
|
||||
return scales.diverging2(domain)
|
||||
case 'greys':
|
||||
return scales.greys(domain)
|
||||
case 'nominalMuted':
|
||||
return scales.nominalMuted()
|
||||
case 'nominalBold':
|
||||
return scales.nominalBold()
|
||||
case 'nominal':
|
||||
default:
|
||||
return scales.nominal()
|
||||
}
|
||||
}
|
||||
|
||||
export function selectColorPalette(key: string): React.FC<ChipsProps> {
|
||||
if (key === 'nominal' || key === 'nominalMuted' || key === 'nominalBold') {
|
||||
return ColorChips
|
||||
}
|
||||
return ContinuousBand
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import {
|
||||
ChoiceGroup,
|
||||
IChoiceGroupOption,
|
||||
IChoiceGroupStyles,
|
||||
} from '@fluentui/react'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { ScaleType } from '@thematic/core'
|
||||
|
||||
interface ScaleTypeChoiceGroupProps {
|
||||
selectedType: ScaleType
|
||||
onChange?: (scaleType: ScaleType) => void
|
||||
label?: string
|
||||
suppressQuantile?: boolean
|
||||
styles?: IChoiceGroupStyles
|
||||
}
|
||||
|
||||
const CHOICE_STYLE = {
|
||||
flexContainer: { display: 'flex', justifyContent: 'space-around' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a strongly typed ChoiceGroup for selecting thematic ScaleTypes.
|
||||
*/
|
||||
export const ScaleTypeChoiceGroup: React.FC<ScaleTypeChoiceGroupProps> = ({
|
||||
selectedType,
|
||||
onChange,
|
||||
label,
|
||||
suppressQuantile = false,
|
||||
styles,
|
||||
}) => {
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
...CHOICE_STYLE,
|
||||
...styles,
|
||||
}),
|
||||
[styles],
|
||||
)
|
||||
const typeOptions = useTypeOptions(suppressQuantile)
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(_, option) => {
|
||||
onChange && onChange(option.key)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<ChoiceGroup
|
||||
styles={style}
|
||||
label={label}
|
||||
options={typeOptions}
|
||||
selectedKey={selectedType}
|
||||
onChange={handleTypeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useTypeOptions(suppressQuantile = false): IChoiceGroupOption[] {
|
||||
return useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
key: 'linear',
|
||||
text: 'Linear',
|
||||
},
|
||||
{
|
||||
key: 'log',
|
||||
text: 'Log',
|
||||
},
|
||||
]
|
||||
if (!suppressQuantile) {
|
||||
options.push({
|
||||
key: 'quantile',
|
||||
text: 'Quantile',
|
||||
})
|
||||
}
|
||||
return options
|
||||
}, [suppressQuantile])
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft. All rights reserved.
|
||||
* Licensed under the MIT license. See LICENSE file in the project.
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
import { themeLoaded } from '../../state/actions'
|
||||
import { ScaleType, Theme } from '@thematic/core'
|
||||
import {
|
||||
ScaleDropdown,
|
||||
ScaleTypeChoiceGroup,
|
||||
ColorPicker,
|
||||
ColorPickerButton,
|
||||
} from '@thematic/fluent'
|
||||
import { useThematic } from '@thematic/react'
|
||||
|
||||
interface FluentControlsComponentProps {
|
||||
themeLoaded: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const FluentControlsComponent: React.FC<FluentControlsComponentProps> = ({
|
||||
themeLoaded,
|
||||
}) => {
|
||||
const theme = useThematic()
|
||||
const [scale, setScale] = useState<string>('<none>')
|
||||
const handleScaleChange = useCallback((e, option) => setScale(option.key), [])
|
||||
const [scaleType, setScaleType] = useState<ScaleType>(ScaleType.Linear)
|
||||
const handleScaleTypeChange = useCallback(type => setScaleType(type), [])
|
||||
const handlePickerChange = useCallback(t => themeLoaded(t), [themeLoaded])
|
||||
return (
|
||||
<Container>
|
||||
<Description>
|
||||
The @thematic/fluent package contains a few custom Fluent controls you
|
||||
can use in your applications to allow Thematic-specific interactions.
|
||||
</Description>
|
||||
<Controls>
|
||||
<Control>
|
||||
<Description>
|
||||
<Label>ScaleDropdown:</Label> a Dropdown that pre-loads Thematic
|
||||
scale options.
|
||||
</Description>
|
||||
<ScaleDropdown
|
||||
placeholder={'Choose scale'}
|
||||
onChange={handleScaleChange}
|
||||
/>
|
||||
<Action> onChange: {scale}</Action>
|
||||
</Control>
|
||||
<Control>
|
||||
<Description>
|
||||
<Label>ScaleTypeChoiceGroup:</Label> a ChoiceGroup that pre-loads
|
||||
Thematic scale types.
|
||||
</Description>
|
||||
<ScaleTypeChoiceGroup
|
||||
selectedType={scaleType}
|
||||
onChange={handleScaleTypeChange}
|
||||
/>
|
||||
<Action> onChange: {scaleType}</Action>
|
||||
</Control>
|
||||
<Control>
|
||||
<Description>
|
||||
<Label>ColorPicker:</Label> a ColorPicker that emits Thematic
|
||||
parameters.
|
||||
</Description>
|
||||
<ColorPicker theme={theme} onChange={handlePickerChange} />
|
||||
<Action> onChange: {theme.application().accent().hex()}</Action>
|
||||
</Control>
|
||||
<Control>
|
||||
<Description>
|
||||
<Label>ColorPickerButton:</Label> a DropdownButton that hosts a
|
||||
Thematic ColorPicker.
|
||||
</Description>
|
||||
<ColorPickerButton theme={theme} onChange={handlePickerChange} />
|
||||
<Action> onChange: {theme.application().accent().hex()}</Action>
|
||||
</Control>
|
||||
</Controls>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const FluentControls = connect(null, {
|
||||
themeLoaded,
|
||||
})(FluentControlsComponent)
|
||||
|
||||
const Container = styled.div`
|
||||
font-size: 14px;
|
||||
overflow-y: scroll;
|
||||
`
|
||||
|
||||
const Description = styled.p``
|
||||
|
||||
const Controls = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
`
|
||||
|
||||
const Control = styled.div`
|
||||
width: 320px;
|
||||
padding: 8px;
|
||||
margin: 8px;
|
||||
//border: 1px solid ${({ theme }) => theme.application().border().hex()};
|
||||
`
|
||||
|
||||
const Label = styled.span`
|
||||
font-weight: bold;
|
||||
color: ${({ theme }) => theme.application().accent().hex()};
|
||||
`
|
||||
|
||||
const Action = styled.p`
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
color: ${({ theme }) => theme.application().warning().hex()};
|
||||
`
|
Загрузка…
Ссылка в новой задаче