diff --git a/packages/graphic-walker/src/components/visualConfig/index.tsx b/packages/graphic-walker/src/components/visualConfig/index.tsx index 7b27cbcb..0abbdb63 100644 --- a/packages/graphic-walker/src/components/visualConfig/index.tsx +++ b/packages/graphic-walker/src/components/visualConfig/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { observer } from 'mobx-react-lite'; import { runInAction, toJS } from 'mobx'; import { useTranslation } from 'react-i18next'; @@ -6,7 +6,7 @@ import { SketchPicker } from 'react-color'; import { useGlobalStore } from '../../store'; import { GLOBAL_CONFIG } from '../../config'; -import { IVisualConfig } from '../../interfaces'; +import { IConfigScale, IVisualConfig } from '../../interfaces'; import PrimaryButton from '../button/primary'; import DefaultButton from '../button/default'; @@ -14,9 +14,57 @@ import Modal from '../modal'; import Toggle from '../toggle'; import DropdownSelect from '../dropdownSelect'; import { ColorSchemes, extractRGBA } from './colorScheme'; +import { RangeScale } from './range-scale'; const DEFAULT_COLOR_SCHEME = ['#5B8FF9', '#FF6900', '#FCB900', '#7BDCB5', '#00D084', '#8ED1FC', '#0693E3', '#ABB8C3', '#EB144C', '#F78DA7', '#9900EF']; +function useScale(minRange: number, maxRange: number, defaultMinRange?: number, defaultMaxRange?: number) { + const [enableMinDomain, setEnableMinDomain] = useState(false); + const [enableMaxDomain, setEnableMaxDomain] = useState(false); + const [enableRange, setEnableRange] = useState(false); + const [domainMin, setDomainMin] = useState(0); + const [domainMax, setDomainMax] = useState(100); + const [rangeMin, setRangeMin] = useState(defaultMinRange ?? minRange); + const [rangeMax, setRangeMax] = useState(defaultMaxRange ?? maxRange); + const setValue = useCallback((value: IConfigScale) => { + setEnableMaxDomain(value.domainMax !== undefined); + setEnableMinDomain(value.domainMin !== undefined); + setEnableRange(value.rangeMax !== undefined || value.rangeMin !== undefined); + setDomainMin(value.domainMin ?? 0); + setDomainMax(value.domainMax ?? 100); + setRangeMax(value.rangeMax ?? defaultMaxRange ?? maxRange); + setRangeMin(value.rangeMin ?? defaultMinRange ?? minRange); + }, []); + + const value = useMemo( + () => ({ + ...(enableMaxDomain ? { domainMax } : {}), + ...(enableMinDomain ? { domainMin } : {}), + ...(enableRange ? { rangeMax, rangeMin } : {}), + }), + [enableMaxDomain && domainMax, enableMinDomain && domainMin, enableRange && rangeMax, enableRange && rangeMin] + ); + + return { + value, + setValue, + enableMaxDomain, + enableMinDomain, + enableRange, + rangeMax, + rangeMin, + domainMax, + domainMin, + setEnableMinDomain, + setEnableMaxDomain, + setEnableRange, + setDomainMin, + setDomainMax, + setRangeMin, + setRangeMax, + }; +} + const VisualConfigPanel: React.FC = (props) => { const { commonStore, vizStore } = useGlobalStore(); const { showVisualConfigPanel } = commonStore; @@ -48,6 +96,8 @@ const VisualConfigPanel: React.FC = (props) => { const [defaultColor, setDefaultColor] = useState({ r: 91, g: 143, b: 249, a: 1 }); const [displayColorPicker, setDisplayColorPicker] = useState(false); const [colorPalette, setColorPalette] = useState(''); + const opacityValue = useScale(0, 1, 0.3, 0.8); + const sizeValue = useScale(0, 100); useEffect(() => { setZeroScale(visualConfig.zeroScale); @@ -61,6 +111,8 @@ const VisualConfigPanel: React.FC = (props) => { normalizedNumberFormat: visualConfig.format.normalizedNumberFormat, }); setColorPalette(visualConfig.colorPalette ?? ''); + opacityValue.setValue(visualConfig.scale?.opacity ?? {}); + sizeValue.setValue(visualConfig.scale?.size ?? {}); }, [showVisualConfigPanel]); return ( @@ -75,76 +127,78 @@ const VisualConfigPanel: React.FC = (props) => { setDisplayColorPicker(false); }} > - <div className="mb-2"> - <h2 className="text-lg mb-4">Scheme</h2> - <div className="flex"> - <div className="flex space-x-6"> - <div> - <label className="block text-xs font-medium leading-6">Primary Color</label> - <div - onClick={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - > + {coordSystem === 'generic' && ( + <> + <div className="mb-2"> + <h2 className="text-lg mb-4">{t('config.scheme')}</h2> + <div className="flex space-x-6"> + <div> + <label className="block text-xs font-medium leading-6">{t('config.primary_color')}</label> <div - className="w-8 h-5 border-2" - style={{ backgroundColor: `rgba(${defaultColor.r},${defaultColor.g},${defaultColor.b},${defaultColor.a})` }} onClick={(e) => { e.stopPropagation(); e.preventDefault(); - setDisplayColorPicker(true); }} - ></div> - <div className="absolute left-32 top-22 index-40"> - {displayColorPicker && ( - <SketchPicker - presetColors={DEFAULT_COLOR_SCHEME} - color={defaultColor} - onChange={(color, event) => { - setDefaultColor({ - ...color.rgb, - a: color.rgb.a ?? 1, - }); - }} - /> - )} + > + <div + className="w-8 h-5 border-2" + style={{ backgroundColor: `rgba(${defaultColor.r},${defaultColor.g},${defaultColor.b},${defaultColor.a})` }} + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + setDisplayColorPicker(true); + }} + ></div> + <div className="absolute left-32 top-22 index-40"> + {displayColorPicker && ( + <SketchPicker + presetColors={DEFAULT_COLOR_SCHEME} + color={defaultColor} + onChange={(color, event) => { + setDefaultColor({ + ...color.rgb, + a: color.rgb.a ?? 1, + }); + }} + /> + )} + </div> </div> </div> - </div> - <div> - <label className="block text-xs font-medium leading-6">Color Palette</label> - <DropdownSelect - buttonClassName="w-48" - selectedKey={colorPalette} - onSelect={setColorPalette} - options={ColorSchemes.map((scheme) => ({ - value: scheme.name, - label: ( - <> - <div key={scheme.name} className="flex flex-col justify-start items-center"> - <div className="font-light">{scheme.name}</div> - <div className="flex w-full"> - {scheme.value.map((c, index) => { - return <div key={index} className="w-4 h-4 flex-shrink" style={{ backgroundColor: `${c}` }}></div>; - })} + <div> + <label className="block text-xs font-medium leading-6">{t('config.color_palette')}</label> + <DropdownSelect + buttonClassName="w-48" + selectedKey={colorPalette} + onSelect={setColorPalette} + options={ColorSchemes.map((scheme) => ({ + value: scheme.name, + label: ( + <> + <div key={scheme.name} className="flex flex-col justify-start items-center"> + <div className="font-light">{scheme.name}</div> + <div className="flex w-full"> + {scheme.value.map((c, index) => { + return ( + <div key={index} className="w-4 h-4 flex-shrink" style={{ backgroundColor: `${c}` }}></div> + ); + })} + </div> </div> - </div> - </> - ), - }))} - /> + </> + ), + }))} + /> + </div> </div> + <label className="block text-xs font-medium leading-6">{t('config.opacity')}</label> + <RangeScale {...opacityValue} text="opacity" maxRange={1} minRange={0} /> + <label className="block text-xs font-medium leading-6">{t('config.size')}</label> + <RangeScale {...sizeValue} text="size" maxRange={100} minRange={0} /> </div> - </div> - - {/* {ColorSchemes.map((scheme) => { - return ( - - ); - })} */} - </div> - <hr className="my-4" /> + <hr className="my-4" /> + </> + )} <h2 className="text-lg mb-4">{t('config.format')}</h2> <p className="text-xs"> {t(`config.formatGuidesDocs`)}:{' '} @@ -259,6 +313,7 @@ const VisualConfigPanel: React.FC = (props) => { vizStore.setVisualConfig('primaryColor', `rgba(${defaultColor.r},${defaultColor.g},${defaultColor.b},${defaultColor.a})`); vizStore.setVisualConfig('colorPalette', colorPalette); vizStore.setVisualConfig('useSvg', svg); + vizStore.setVisualConfig('scale', { opacity: opacityValue.value, size: sizeValue.value }); commonStore.setShowVisualConfigPanel(false); }); }} diff --git a/packages/graphic-walker/src/components/visualConfig/range-scale.tsx b/packages/graphic-walker/src/components/visualConfig/range-scale.tsx new file mode 100644 index 00000000..214e0f15 --- /dev/null +++ b/packages/graphic-walker/src/components/visualConfig/range-scale.tsx @@ -0,0 +1,218 @@ +import React, { useRef, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +const SliderContainer = styled.div` + padding-top: 8px; + .thumb, + .thumb::-webkit-slider-thumb { + -webkit-appearance: none; + } + + .thumb { + pointer-events: none; + position: absolute; + height: 0; + width: 200px; + outline: none; + } + + .thumb--left { + z-index: 3; + } + + .thumb--right { + z-index: 4; + } + + .thumb::-webkit-slider-thumb { + background-color: #f1f5f7; + border: none; + border-radius: 50%; + box-shadow: 0 0 1px 1px #ced4da; + cursor: pointer; + height: 18px; + width: 18px; + margin-top: 4px; + pointer-events: all; + position: relative; + } + + .thumb::-moz-range-thumb { + background-color: #f1f5f7; + border: none; + border-radius: 50%; + box-shadow: 0 0 1px 1px #ced4da; + cursor: pointer; + height: 18px; + width: 18px; + margin-top: 4px; + pointer-events: all; + position: relative; + } +`; + +function Checkbox(props: { inputKey: string; title: string; value: boolean; onChange: (v: boolean) => void }) { + return ( + <div className="flex justify-center items-center space-x-1 text-xs"> + <input + type="checkbox" + className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" + checked={props.value} + id={`${props.inputKey}`} + aria-describedby={`${props.inputKey}_label`} + title={props.title} + onChange={({ target: { checked } }) => props.onChange(checked)} + /> + <label id={`${props.inputKey}_label`} htmlFor={`${props.inputKey}`} title={props.title}> + {props.title} + </label> + </div> + ); +} + +const MultiRangeSlider = ({ min, max, minVal, maxVal, setMinVal, setMaxVal, factor }) => { + const minValRef = useRef(minVal); + const maxValRef = useRef(maxVal); + const range = useRef<HTMLDivElement>(null); + + // Convert to percentage + const getPercent = useCallback((value) => Math.round(((value - min) / (max - min)) * 100), [min, max]); + + // Set width of the range to decrease from the left side + useEffect(() => { + const minPercent = getPercent(minVal); + const maxPercent = getPercent(maxValRef.current); + + if (range.current) { + range.current.style.left = `${minPercent}%`; + range.current.style.width = `${maxPercent - minPercent}%`; + } + }, [minVal, getPercent]); + + // Set width of the range to decrease from the right side + useEffect(() => { + const minPercent = getPercent(minValRef.current); + const maxPercent = getPercent(maxVal); + + if (range.current) { + range.current.style.width = `${maxPercent - minPercent}%`; + } + }, [maxVal, getPercent]); + + return ( + <SliderContainer> + <input + type="range" + min={min * factor} + max={max * factor} + value={minVal * factor} + onChange={(event) => { + const value = Math.min(Number(event.target.value), maxVal * factor - 1); + setMinVal(value / factor); + minValRef.current = value / factor; + }} + className="thumb thumb--left" + style={{ zIndex: minVal > max - 100 ? '5' : undefined }} + /> + <input + type="range" + min={min * factor} + max={max * factor} + value={maxVal * factor} + onChange={(event) => { + const value = Math.max(Number(event.target.value), minVal * factor + 1); + setMaxVal(value / factor); + maxValRef.current = value / factor; + }} + className="thumb thumb--right" + /> + + <div className="relative w-48"> + <div className="absolute rounded h-1 bg-gray-200 dark:bg-gray-700 w-full z-[1]" /> + <div ref={range} className="absolute rounded h-1 bg-indigo-400 z-[2]" /> + <div className="text-xs absolute left-1.5 text-gray-900 dark:text-gray-50 mt-3">{minVal}</div> + <div className="text-xs absolute -right-1 text-gray-900 dark:text-gray-50 mt-3">{maxVal}</div> + </div> + </SliderContainer> + ); +}; + +export function RangeScale(props: { + text: string; + maxRange: number; + minRange: number; + enableMaxDomain: boolean; + enableMinDomain: boolean; + enableRange: boolean; + rangeMax: number; + rangeMin: number; + domainMax: number; + domainMin: number; + setEnableMinDomain: (v: boolean) => void; + setEnableMaxDomain: (v: boolean) => void; + setEnableRange: (v: boolean) => void; + setDomainMin: (v: number) => void; + setDomainMax: (v: number) => void; + setRangeMin: (v: number) => void; + setRangeMax: (v: number) => void; +}) { + const { t } = useTranslation(); + + return ( + <div className="flex space-x-6 my-2"> + <div className="flex flex-col space-y-1 items-start"> + <Checkbox + inputKey={`min_domain_${props.text}`} + value={props.enableMinDomain} + title={t('config.min_domain')} + onChange={props.setEnableMinDomain} + /> + <input + value={props.domainMin} + onChange={(e) => { + const v = Number(e.target.value); + if (!isNaN(v)) { + props.setDomainMin(v); + } + }} + type="number" + disabled={!props.enableMinDomain} + className="block w-full rounded-md border-0 py-1 px-2 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 dark:bg-zinc-900 dark:border-gray-700 focus:ring-1 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + /> + </div> + <div className="flex flex-col space-y-1 items-start"> + <Checkbox + inputKey={`max_domain_${props.text}`} + value={props.enableMaxDomain} + title={t('config.max_domain')} + onChange={props.setEnableMaxDomain} + /> + <input + value={props.domainMax} + onChange={(e) => { + const v = Number(e.target.value); + if (!isNaN(v)) { + props.setDomainMax(v); + } + }} + type="number" + disabled={!props.enableMaxDomain} + className="block w-full rounded-md border-0 py-1 px-2 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 dark:bg-zinc-900 dark:border-gray-700 focus:ring-1 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + /> + </div> + <div className="flex flex-col space-y-1 items-start w-48"> + <Checkbox inputKey={`range_${props.text}`} value={props.enableRange} title={t('config.range')} onChange={props.setEnableRange} /> + <MultiRangeSlider + max={props.maxRange} + min={props.minRange} + maxVal={props.rangeMax} + minVal={props.rangeMin} + setMaxVal={props.setRangeMax} + setMinVal={props.setRangeMin} + factor={props.maxRange < 2 ? 100 : 1} + /> + </div> + </div> + ); +} diff --git a/packages/graphic-walker/src/interfaces.ts b/packages/graphic-walker/src/interfaces.ts index 0f51e28c..e7d0d87b 100644 --- a/packages/graphic-walker/src/interfaces.ts +++ b/packages/graphic-walker/src/interfaces.ts @@ -231,6 +231,13 @@ export type IStackMode = 'none' | 'stack' | 'normalize' | 'zero' | 'center'; export type ICoordMode = 'generic' | 'geographic'; +export type IConfigScale = { + rangeMax?: number, + rangeMin?: number, + domainMin?: number, + domainMax?: number, +} + export interface IVisualConfig { defaultAggregated: boolean; geoms: string[]; @@ -253,6 +260,10 @@ export interface IVisualConfig { }; primaryColor?:string; colorPalette?: string; + scale?: { + opacity: IConfigScale, + size: IConfigScale + }; resolve: { x?: boolean; y?: boolean; diff --git a/packages/graphic-walker/src/locales/en-US.json b/packages/graphic-walker/src/locales/en-US.json index 3afdf990..92a1e0c7 100644 --- a/packages/graphic-walker/src/locales/en-US.json +++ b/packages/graphic-walker/src/locales/en-US.json @@ -1,5 +1,13 @@ { "config": { + "scheme": "Scheme", + "primary_color": "Primary Color", + "color_palette": "Color Palette", + "opacity": "Opacity", + "size":"Size", + "min_domain": "Domain Min", + "max_domain": "Domain Max", + "range": "Range", "format": "Format", "numberFormat": "Number format", "timeFormat": "Time format", diff --git a/packages/graphic-walker/src/locales/ja-JP.json b/packages/graphic-walker/src/locales/ja-JP.json index 1c61531c..6b5d89ce 100644 --- a/packages/graphic-walker/src/locales/ja-JP.json +++ b/packages/graphic-walker/src/locales/ja-JP.json @@ -1,5 +1,13 @@ { "config": { + "scheme": "スキーム", + "primary_color": "プライマリカラー", + "color_palette": "カラーパレット", + "opacity": "不透明度", + "size": "サイズ", + "min_domain": "最小ドメイン", + "max_domain": "最大ドメイン", + "range": "範囲", "format": "フォーマット", "numberFormat": "数字", "timeFormat": "日付", @@ -148,7 +156,7 @@ "auto_title": "ビュー {{idx}}", "chart_name": "ビュー名" }, - "askviz":{ + "askviz": { "placeholder": "データセットから描画したい視覚化は何ですか?" }, "tabpanel": { diff --git a/packages/graphic-walker/src/locales/zh-CN.json b/packages/graphic-walker/src/locales/zh-CN.json index efc7d23f..f946a5bb 100644 --- a/packages/graphic-walker/src/locales/zh-CN.json +++ b/packages/graphic-walker/src/locales/zh-CN.json @@ -1,5 +1,13 @@ { "config": { + "scheme": "方案", + "primary_color": "主要颜色", + "color_palette": "调色板", + "opacity": "不透明度", + "size": "尺寸", + "min_domain": "最小域值", + "max_domain": "最大域值", + "range": "范围", "format": "格式", "numberFormat": "数字格式", "timeFormat": "时间格式", diff --git a/packages/graphic-walker/src/renderer/specRenderer.tsx b/packages/graphic-walker/src/renderer/specRenderer.tsx index 7307da6a..e92ef7e8 100644 --- a/packages/graphic-walker/src/renderer/specRenderer.tsx +++ b/packages/graphic-walker/src/renderer/specRenderer.tsx @@ -73,6 +73,7 @@ const SpecRenderer = forwardRef<IReactVegaHandler, SpecRendererProps>(function ( useSvg, primaryColor, colorPalette, + scale } = visualConfig; const rows = draggableFieldState.rows; @@ -217,6 +218,7 @@ const SpecRenderer = forwardRef<IReactVegaHandler, SpecRendererProps>(function ( useSvg={useSvg} channelScales={channelScales} dark={dark} + scale={scale} /> )} </Resizable> diff --git a/packages/graphic-walker/src/store/visualSpecStore.ts b/packages/graphic-walker/src/store/visualSpecStore.ts index 8bde1c21..81c06630 100644 --- a/packages/graphic-walker/src/store/visualSpecStore.ts +++ b/packages/graphic-walker/src/store/visualSpecStore.ts @@ -401,6 +401,7 @@ export class VizSpecStore { case configKey === 'limit': case configKey === 'primaryColor': case configKey === 'colorPalette': + case configKey === 'scale': case configKey === 'stack': { return (config[configKey] = value); } diff --git a/packages/graphic-walker/src/vis/react-vega.tsx b/packages/graphic-walker/src/vis/react-vega.tsx index d5e26a06..414605c7 100644 --- a/packages/graphic-walker/src/vis/react-vega.tsx +++ b/packages/graphic-walker/src/vis/react-vega.tsx @@ -6,7 +6,7 @@ import type { ScenegraphEvent } from 'vega'; import styled from 'styled-components'; import { GLOBAL_CONFIG } from '../config'; import { useVegaExportApi } from '../utils/vegaApiExport'; -import { IViewField, IRow, IStackMode, VegaGlobalConfig, IVegaChartRef, IChannelScales, IDarkMode } from '../interfaces'; +import { IViewField, IRow, IStackMode, VegaGlobalConfig, IVegaChartRef, IChannelScales, IDarkMode, IConfigScale } from '../interfaces'; import { getVegaTimeFormatRules } from './temporalFormat'; import { getSingleView, resolveScales } from './spec/view'; import { NULL_FIELD } from './spec/field'; @@ -55,6 +55,10 @@ interface ReactVegaProps { useSvg?: boolean; dark?: IDarkMode; channelScales?: IChannelScales; + scale?: { + opacity: IConfigScale, + size: IConfigScale + } } const click$ = new Subject<ScenegraphEvent>(); @@ -109,10 +113,24 @@ const ReactVega = forwardRef<IReactVegaHandler, ReactVegaProps>(function ReactVe // format locale = 'en-US', useSvg, - channelScales, + channelScales : channelScaleRaw, + scale } = props; const [viewPlaceholders, setViewPlaceholders] = useState<React.MutableRefObject<HTMLDivElement>[]>([]); const mediaTheme = useCurrentMediaTheme(dark); + const channelScales = channelScaleRaw ?? {}; + if (scale?.opacity) { + channelScales.opacity = { + ...channelScales.opacity ?? {}, + ...scale.opacity + }; + } + if (scale?.size) { + channelScales.size = { + ...channelScales.size ?? {}, + ...scale.size + }; + } // const themeConfig = builtInThemes[themeKey]?.[mediaTheme]; // const vegaConfig = useMemo(() => {