diff --git a/README.md b/README.md index 04eb7db2e..d4eac8c93 100644 --- a/README.md +++ b/README.md @@ -16,26 +16,26 @@ Graphic Walker is a different open-source alternative to Tableau. It allows data ### Why is it different? -It is extremely easy to embed in your apps just as a component 🎉! The original purpose of graphic-walker is not to be a heavy BI platform, but a easy to embed, lite, plugin. +It is extremely easy to embed in your apps just as a React component 🎉! The original purpose of graphic-walker is not to be a heavy BI platform, but a easy to embed, lite, plugin. ### Main features + A user friendly drag and drop based interaction for exploratory data analysis with visualizations. + A Data Explainer which explains why some patterns occur / what may cause them (like salesforce einstein). + Using web workers to handle computational tasks which allow you to use it as a pure front-end app. -+ Graphic Walker now supports Dark Theme! 🤩 -+ Spatial visualization. ++ A general query interface for submit data queries to your own computation service. You can have a look at how we using DuckDB to handle data queries in [PyGWalker](https://github.com/kanaries/pygwalker) ++ Light Theme / Dark Theme! 🤩 ++ Spatial visualization. (supports GeoJSON, TopoJSON) + Natural language / Chat interface. Ask question about your data! + A grammar of graphics based visual analytic user interface where users can build visualizations from low-level visual channel encodings. (based on [vega-lite](https://vega.github.io/vega-lite/)) https://github.com/Kanaries/graphic-walker/assets/22167673/15d34bed-9ccc-42da-a2f4-9859ea36fa65 -> Graphic Walker is a lite visual analytic component. If you are interested in more advanced data analysis software, check our related project [RATH](https://github.com/Kanaries/Rath), an augmented analytic BI with automated insight discovery, causal analysis and visualization auto generation based on human's visual perception. ## Usage for End Users First, upload your Data(csv/json) file, preview your data, and define the analytic type of columns (dimension or measure). -> We are developing more types of data sources. You are welcome to raise an issue telling us the types of sources you are using. If you are a developer, graphic-walker can be used as an embedding component, and you can pass your parsed data source to it. For example, [Rath](https://github.com/Kanaries/Rath) uses graphic-walker as an embedding components, and it supports many common data sources. You can load your data in [Rath](https://github.com/Kanaries/Rath) and bring the data into graphic-walker. In this way, users can also benefit from data cleaning and transformation features in [Rath](https://github.com/Kanaries/Rath). +> We are developing more types of data sources. You are welcome to raise an issue telling us the types of sources you are using. If you are a developer, graphic-walker can be used as an embedding component, and you can pass your parsed data source to it. For example, [Rath](https://github.com/Kanaries/Rath) uses graphic-walker as an embeded component, and it supports many common data sources. You can load your data in [Rath](https://github.com/Kanaries/Rath) and bring the data into graphic-walker. In this way, users can also benefit from data cleaning and transformation features in [Rath](https://github.com/Kanaries/Rath). ![graphic walker dataset upload](https://docs-us.oss-us-west-1.aliyuncs.com/images/graphic-walker/20230811/gw-create-ds.png) @@ -296,6 +296,56 @@ When you are using Server-side computation, you should provide `rawFields` toget Customize the toolbar. +#### `channelScales`: optional _{ [`IChannelScales`](./packages/graphic-walker/src/interfaces.ts) }_ + +Customize the scale of color, opacity, and size channel. +see [Vega Docs](https://vega.github.io/vega/docs/schemes/#reference) for available color schemes. + +Here are some examples: +```ts +// use a another color pattren +const channelScales = { + color: { + scheme: "tableau10" + } +} +// use a diffrent color pattren in dark mode and light mode +const channelScales = { + color({theme}) { + if(theme === 'dark') { + return { + scheme: 'darkblue' + } + }else { + return { + scheme: 'lightmulti' + } + } + } +} +// use a custom color palette +const channelScales = { + color: { + range: ['red', 'blue', '#000000'] + } +} +// customing opacity +const channelScales = { + // map value of 0 - 255 to opacity 0 - 1 + opacity: { + range: [0, 1], + domain: [0, 255], + } +} +// set min radius for arc chart +const channelScales = { + radius: { + rangeMin: 20 + } +} + +``` + ### Ref ```ts diff --git a/computation.md b/computation.md index 2f86e8cd0..f7126bbe8 100644 --- a/computation.md +++ b/computation.md @@ -153,9 +153,10 @@ type IExpParameter = ( ); interface IExpression { - op: 'bin' | 'log2' | 'log10' | 'one' | 'binCount'; + op: 'bin' | 'one' | 'binCount'|'log'; params: IExpParameter[]; as: string; + num?:number; } interface ITransformField { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 02ea68f0b..e5dff8e6c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -10,7 +10,7 @@ "tauri": "tauri" }, "dependencies": { - "@kanaries/graphic-walker": "0.4.3", + "@kanaries/graphic-walker": "0.4.13", "@tauri-apps/api": "^1.1.0", "react": "^17.x", "react-dom": "^17.x" diff --git a/packages/graphic-walker/package.json b/packages/graphic-walker/package.json index 0168217ac..5b5f9b42e 100644 --- a/packages/graphic-walker/package.json +++ b/packages/graphic-walker/package.json @@ -1,6 +1,6 @@ { "name": "@kanaries/graphic-walker", - "version": "0.4.3", + "version": "0.4.13", "scripts": { "dev:front_end": "vite --host", "dev": "npm run dev:front_end", @@ -40,6 +40,7 @@ "@kanaries/web-data-loader": "^0.1.7", "@tailwindcss/forms": "^0.5.4", "autoprefixer": "^10.3.5", + "canvas-size": "^1.2.6", "d3-format": "^3.1.0", "d3-scale": "^4.0.2", "d3-time-format": "^4.1.0", @@ -54,6 +55,8 @@ "postinstall-postinstall": "^2.1.0", "re-resizable": "^6.9.8", "react-color": "^2.19.3", + "react-dropzone": "^14.2.3", + "react-error-boundary": "^4.0.11", "react-i18next": "^11.18.6", "react-leaflet": "^4.2.1", "react-shadow": "^20.0.0", diff --git a/packages/graphic-walker/src/App.tsx b/packages/graphic-walker/src/App.tsx index c80ea4585..5026ec107 100644 --- a/packages/graphic-walker/src/App.tsx +++ b/packages/graphic-walker/src/App.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useRef, useMemo, useState } from 'react'; +import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react'; import { observer } from 'mobx-react-lite'; import { useTranslation } from 'react-i18next'; -import { IGeographicData, IComputationFunction, IDarkMode, IMutField, IRow, ISegmentKey, IThemeKey, Specification } from './interfaces'; +import { IGeographicData, IComputationFunction, IDarkMode, IMutField, IRow, ISegmentKey, IThemeKey, Specification, IGeoDataItem, VegaGlobalConfig, IChannelScales } from './interfaces'; import type { IReactVegaHandler } from './vis/react-vega'; import VisualSettings from './visualSettings'; import PosFields from './fields/posFields'; @@ -19,10 +19,20 @@ import DatasetConfig from './dataSource/datasetConfig'; import { useCurrentMediaTheme } from './utils/media'; import CodeExport from './components/codeExport'; import VisualConfig from './components/visualConfig'; +import ExplainData from './components/explainData'; import GeoConfigPanel from './components/leafletRenderer/geoConfigPanel'; import type { ToolbarItemProps } from './components/toolbar'; +import ClickMenu from './components/clickMenu'; +import { + LightBulbIcon, +} from "@heroicons/react/24/outline"; import AskViz from './components/askViz'; import { getComputation } from './computation/clientComputation'; +import LogPanel from './fields/datasetFields/logPanel'; +import BinPanel from './fields/datasetFields/binPanel'; +import { ErrorContext } from './utils/reportError'; +import { ErrorBoundary } from "react-error-boundary"; +import Errorpanel from './components/errorpanel'; export interface IGWProps { dataSource?: IRow[]; @@ -38,6 +48,7 @@ export interface IGWProps { fieldKeyGuard?: boolean; /** @default "vega" */ themeKey?: IThemeKey; + themeConfig?: VegaGlobalConfig; dark?: IDarkMode; storeRef?: React.MutableRefObject; computation?: IComputationFunction; @@ -55,6 +66,9 @@ export interface IGWProps { } }; computationTimeout?: number; + onError?: (err: Error) => void; + geoList?: IGeoDataItem[]; + channelScales?: IChannelScales; } const App = observer(function App(props) { @@ -67,6 +81,7 @@ const App = observer(function App(props) { hideDataSourceConfig, fieldKeyGuard = true, themeKey = 'vega', + themeConfig, dark = 'media', computation, toolbar, @@ -76,7 +91,7 @@ const App = observer(function App(props) { } = props; const { commonStore, vizStore } = useGlobalStore(); - const { datasets, segmentKey } = commonStore; + const { datasets, segmentKey, vizEmbededMenu } = commonStore; const { t, i18n } = useTranslation(); const curLang = i18n.language; @@ -159,7 +174,20 @@ const App = observer(function App(props) { const rendererRef = useRef(null); + const downloadCSVRef = useRef<{ download: () => void }>({download() {}}); + + const reportError = useCallback((msg: string, code?: number) => { + const err = new Error(`Error${code ? `(${code})`: ''}: ${msg}`); + console.error(err); + props.onError?.(err); + if (code) { + commonStore.updateShowErrorResolutionPanel(code); + } + }, [props.onError]); + return ( + + Something went wrong} onError={props.onError} >
(function App(props) { {enhanceAPI?.features?.askviz && ( )} - + + - + + + + {commonStore.showGeoJSONConfigPanel && }
@@ -201,17 +233,17 @@ const App = observer(function App(props) {
{ - // vizEmbededMenu.show && commonStore.closeEmbededMenu(); - // }} - // onClick={() => { - // vizEmbededMenu.show && commonStore.closeEmbededMenu(); - // }} + onMouseLeave={() => { + vizEmbededMenu.show && commonStore.closeEmbededMenu(); + }} + onClick={() => { + vizEmbededMenu.show && commonStore.closeEmbededMenu(); + }} > {datasets.length > 0 && ( - + )} - {/* {vizEmbededMenu.show && ( + {vizEmbededMenu.show && (
(function App(props) {
- )} */} + )}
@@ -242,6 +274,8 @@ const App = observer(function App(props) { )}
+
+
); }); diff --git a/packages/graphic-walker/src/components/actionMenu/a11y.tsx b/packages/graphic-walker/src/components/actionMenu/a11y.tsx new file mode 100644 index 000000000..c9707f73b --- /dev/null +++ b/packages/graphic-walker/src/components/actionMenu/a11y.tsx @@ -0,0 +1,48 @@ +import { type HTMLAttributes, type MouseEvent, useCallback, useMemo, type KeyboardEvent, type AriaAttributes, type ComponentPropsWithoutRef } from "react"; + + +export type ReactTag = keyof JSX.IntrinsicElements; +export type ElementType = Parameters['onClick']>>[0] extends MouseEvent ? U : never; + +interface IUseMenuButtonOptions { + "aria-expanded": NonNullable; + onPress?: () => void; + disabled?: boolean; +} + +export const useMenuButton = (options: IUseMenuButtonOptions & Omit, keyof IUseMenuButtonOptions>): HTMLAttributes> => { + const { ["aria-expanded"]: expanded, onPress, disabled, className = '', ...attrs } = options; + + const onClick = useCallback((e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onPress?.(); + }, [onPress]); + + const onKeyDown = useCallback((e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + case 'Space': { + onPress?.(); + break; + } + default: { + return; + } + } + e.preventDefault(); + e.stopPropagation(); + }, [onPress]); + + return useMemo(() => ({ + ...attrs, + className: `${className} text-gray-500 dark:text-gray-400 ${disabled ? 'cursor-default text-opacity-50' : 'cursor-pointer rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-indigo-500 hover:bg-indigo-100/20 dark:hover:bg-indigo-800/20 hover:text-indigo-700 dark:hover:text-indigo-200'}`, + role: 'button', + "aria-haspopup": 'menu', + "aria-expanded": expanded, + "aria-disabled": disabled, + tabIndex: disabled ? undefined : 0, + onClick, + onKeyDown, + }) as HTMLAttributes>, [onClick, onKeyDown, expanded, disabled, className, attrs]); +}; diff --git a/packages/graphic-walker/src/components/actionMenu/index.tsx b/packages/graphic-walker/src/components/actionMenu/index.tsx new file mode 100644 index 000000000..eb804e022 --- /dev/null +++ b/packages/graphic-walker/src/components/actionMenu/index.tsx @@ -0,0 +1,167 @@ +import React, { Fragment, type HTMLAttributes, memo, type ReactElement, createContext, useState, useContext, useRef, useCallback, type ComponentPropsWithoutRef } from "react"; +import { Menu, Transition } from "@headlessui/react"; +import { type ReactTag, type ElementType, useMenuButton } from './a11y'; +import ActionMenuItemList, { type IActionMenuItem } from "./list"; + + +interface IActionMenuContext { + disabled: boolean; + expanded: boolean; + moveTo(x: number, y: number): void; + open(): void; + close(): void; + _items: readonly IActionMenuItem[]; +} + +const Context = createContext(null); + +interface IActionMenuProps { + menu?: IActionMenuItem[]; + disabled?: boolean; + /** @default false */ + enableContextMenu?: boolean; + title?: string; +} + +const ActionMenu: React.FC, keyof IActionMenuProps>> = (props) => { + const { menu = [], disabled = false, enableContextMenu = false, title, ...attrs } = props; + + const [coord, setCoord] = useState<[x: number, y: number]>([0, 0]); + const buttonRef = useRef(null); + + if (disabled || menu.length === 0) { + return ( +
+ {props.children} +
+ ); + } + + return ( + + {({ open, close }) => { + return ( + + +
{ + e.preventDefault(); + e.stopPropagation(); + setCoord([e.clientX, e.clientY]); + if (!open) { + buttonRef.current?.click(); + } + }} + {...attrs} + > + {props.children} +
+ {open &&
+ ); +}; + +type IActionMenuButtonProps = ( + | { + /** @default "button" */ + as: T; + } + | { + /** @default "button" */ + as?: ReactTag; + } +) & { + onPress?: (ctx: IActionMenuContext | undefined) => void; + /** @deprecated use `onPress()` instead */ + onClick?: () => void; +}; + +const ActionMenuButton = function ActionMenuButton(props: IActionMenuButtonProps & Omit, keyof IActionMenuProps>): ReactElement { + const { as: _as = 'button', onPress, children, ...attrs } = props; + const Component = _as as T; + + const ctx = useContext(Context); + const buttonRef = useRef>(null); + + const handlePress = useCallback(() => { + if (ctx?.disabled) { + return; + } + const btn = buttonRef.current; + if (btn) { + const rect = (btn as unknown as HTMLElement | SVGElement).getBoundingClientRect(); + ctx?.moveTo(rect.x + rect.width, rect.y); + } + if (onPress) { + return onPress(ctx ?? undefined); + } + if (ctx?.expanded) { + ctx.close(); + } else { + ctx?.open(); + } + }, [ctx, onPress]); + + const buttonProps = useMenuButton({ + ...attrs, + "aria-expanded": ctx?.expanded ?? false, + onPress: handlePress, + }); + + if (ctx?.disabled || !ctx?._items.length) { + return
; + } + + return ( + // @ts-expect-error Expression produces a union type that is too complex to represent + + {children} + + ); +}; + + +export default Object.assign(ActionMenu, { + Button: memo(ActionMenuButton), +}); diff --git a/packages/graphic-walker/src/components/actionMenu/list.tsx b/packages/graphic-walker/src/components/actionMenu/list.tsx new file mode 100644 index 000000000..dab28b8ce --- /dev/null +++ b/packages/graphic-walker/src/components/actionMenu/list.tsx @@ -0,0 +1,223 @@ +import React, { memo, type ComponentProps, type ReactElement, createContext, useState, useContext, useMemo, useEffect } from "react"; +import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import styled from "styled-components"; + + +function classNames(...classes: string[]) { + return classes.filter(Boolean).join(" "); +} + +export interface IActionMenuItem { + icon?: ReactElement; + label: string; + disabled?: boolean; + children?: IActionMenuItem[]; + onPress?: () => void; +} + +const List = styled.div` + display: grid; + grid-template-columns: max-content 1fr max-content; + & > div { + /* row */ + display: contents; + > * { + background: inherit; + cursor: pointer; + width: 100%; + height: 100%; + display: flex; + align-items: center; + overflow: hidden; + padding-block: 0.2rem; + padding-inline: 0.2rem; + &:first-child { + padding-left: 0.4rem; + } + } + &[aria-disabled="true"] > * { + opacity: 0.5; + cursor: default; + } + } +`; + +const FixedTarget = memo(function FixedTarget(props: ComponentProps<"div">) { + const { children, ...attrs } = props; + + return ( +
+
+ {children} +
+
+ ); +}); + +interface IActionMenuRootContext { + path: number[]; + setPath: (path: number[]) => void; + onDismiss: () => void; +} + +const Context = createContext(null!); + +interface IActionMenuItemProps { + item: IActionMenuItem; + path: number[]; +} + +const ActionMenuItem = memo(function ActionMenuItem({ item, path }) { + const { icon, label, disabled = false, children = [], onPress } = item; + const [hover, setHover] = useState(false); + const [focus, setFocus] = useState(false); + + const ctx = useContext(Context); + + const expanded = children.length > 0 && ctx.path.length > 0 && path.length <= ctx.path.length && path.every((v, i) => v === ctx.path[i]); + + const active = !disabled && (hover || focus || expanded); + + const basePath = useMemo(() => { + let idx = ctx.path.findIndex((v, i) => v !== path[i]); + if (idx !== -1) { + return ctx.path.slice(0, idx); + } + return ctx.path; + }, [ctx.path, path]); + + useEffect(() => { + if (children.length === 0 && (hover || focus)) { + if (basePath.join(".") !== ctx.path.join(".")) { + ctx.setPath(basePath); + } + } + }, [children.length, hover, focus, ctx, basePath]); + + return ( +
{ + if (disabled || children.length) { + e.stopPropagation(); + e.preventDefault(); + return; + } + if (!disabled) { + onPress?.(); + ctx.onDismiss(); + } + }} + onFocus={() => { + if (!disabled) { + setFocus(true); + } + if (children.length && !expanded) { + ctx.setPath(path); + } + }} + onBlur={() => { + setFocus(false); + }} + onMouseEnter={() => { + setHover(true); + if (children.length && !expanded && !disabled) { + ctx.setPath(path); + } + }} + onMouseLeave={() => { + setHover(false); + }} + onKeyDown={e => { + if (disabled || children.length) { + return; + } + if (e.key === 'Enter' || e.key === 'Space') { + onPress?.(); + ctx.onDismiss(); + } + }} + > + +
+ + {label} + +
+ +
+ ); +}); + +interface IActionMenuItemListProps { + title?: string; + items: IActionMenuItem[]; + path: number[]; +} + +const MenuItemList = memo(function ActionMenuItemList({ items, path, title }) { + + return ( +
+ {title && ( +
+ {title} +
+ )} + + {items.map((item, index) => ( + + ))} + +
+ ); +}); + +const ActionMenuItemRoot = memo<{ onDismiss: () => void; children: any }>(function ActionMenuItemRoot({ onDismiss, children }) { + const [path, setPath] = useState([]); + + return ( + + {children} + + ); +}); + + +export default memo & { onDismiss: () => void }>(function ActionMenuItemList({ onDismiss, items, title }) { + return ( + + + + ); +}); diff --git a/packages/graphic-walker/src/components/askViz/index.tsx b/packages/graphic-walker/src/components/askViz/index.tsx index b5e307a4d..1d447bd9c 100644 --- a/packages/graphic-walker/src/components/askViz/index.tsx +++ b/packages/graphic-walker/src/components/askViz/index.tsx @@ -6,6 +6,7 @@ import Spinner from '../spinner'; import { IViewField } from '../../interfaces'; import { VisSpecWithHistory } from '../../models/visSpecHistory'; import { visSpecDecoder, forwardVisualConfigs } from '../../utils/save'; +import { useTranslation } from 'react-i18next'; type VEGALite = any; @@ -45,6 +46,7 @@ const AskViz: React.FC<{api?: string; headers?: Record}> = (prop const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); const { vizStore } = useGlobalStore(); + const {t} = useTranslation(); const allFields = vizStore.allFields; @@ -66,7 +68,7 @@ const AskViz: React.FC<{api?: string; headers?: Record}> = (prop setQuery(e.target.value)} onKeyDown={(e) => { diff --git a/packages/graphic-walker/src/components/button/base.ts b/packages/graphic-walker/src/components/button/base.ts index aca515b8f..381820335 100644 --- a/packages/graphic-walker/src/components/button/base.ts +++ b/packages/graphic-walker/src/components/button/base.ts @@ -5,4 +5,5 @@ export interface ButtonBaseProps { text: string; disabled?: boolean; className?: string; + icon?: JSX.Element; } \ No newline at end of file diff --git a/packages/graphic-walker/src/components/button/default.tsx b/packages/graphic-walker/src/components/button/default.tsx index 00bfd857b..e65b3d555 100644 --- a/packages/graphic-walker/src/components/button/default.tsx +++ b/packages/graphic-walker/src/components/button/default.tsx @@ -2,7 +2,7 @@ import React from "react"; import { ButtonBaseProps } from "./base"; const DefaultButton: React.FC = (props) => { - const { text, onClick, disabled, className } = props; + const { text, onClick, disabled, className, icon } = props; let btnClassName = "inline-flex items-center rounded border border-gray-300 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50" if (className) { btnClassName = btnClassName + " " + className; @@ -14,6 +14,7 @@ const DefaultButton: React.FC = (props) => { disabled={disabled} > {text} + {icon} ); }; diff --git a/packages/graphic-walker/src/components/errorpanel/index.tsx b/packages/graphic-walker/src/components/errorpanel/index.tsx new file mode 100644 index 000000000..829274e95 --- /dev/null +++ b/packages/graphic-walker/src/components/errorpanel/index.tsx @@ -0,0 +1,62 @@ +import { observer } from 'mobx-react-lite'; +import { useGlobalStore } from '../../store'; +import React from 'react'; +import Modal from '../smallModal'; +import { useTranslation } from 'react-i18next'; +import DefaultButton from '../button/default'; + +export default observer(function ErrorPanel() { + const { commonStore, vizStore } = useGlobalStore(); + const { t } = useTranslation(); + switch (commonStore.showErrorResolutionPanel) { + case 0: + return null; + case 500: + return ( + +
+

Oops!

+

The chart is too large to render. You can try options above:

+

+ 1. Set the chart to a fixed size. + { + vizStore.setChartLayout({ mode: 'fixed', width: 800, height: 600 }); + commonStore.updateShowErrorResolutionPanel(0); + }} + > + Set Now + +

+

+ 2. Change to SVG renderer. + { + vizStore.setVisualConfig('useSvg', true); + commonStore.updateShowErrorResolutionPanel(0); + }} + > + Set Now + +

+

3. Close this modal and edit the chart to reduce chart size.

+
+
+ { + commonStore.updateShowErrorResolutionPanel(0); + return; + }} + /> +
+
+
+
+ ); + } + return null; +}); diff --git a/packages/graphic-walker/src/components/explainData/index.tsx b/packages/graphic-walker/src/components/explainData/index.tsx new file mode 100644 index 000000000..89e5d8d3f --- /dev/null +++ b/packages/graphic-walker/src/components/explainData/index.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState, useRef, useMemo } from "react"; +import Modal from "../modal"; +import { observer } from "mobx-react-lite"; +import { useGlobalStore } from "../../store"; +import { useTranslation } from "react-i18next"; +import { getMeaAggKey } from '../../utils'; +import styled from 'styled-components'; +import embed from "vega-embed"; +import { VegaGlobalConfig, IDarkMode, IThemeKey, IField, IRow, IPredicate } from "../../interfaces"; +import { builtInThemes } from '../../vis/theme'; +import { explainBySelection } from "../../lib/insights/explainBySelection" + +const Container = styled.div` + height: 50vh; + overflow-y: hidden; +`; +const TabsList = styled.div` + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: stretch; + height: 100%; + overflow-y: scroll +`; + +const Tab = styled.div` + margin-block: 0.2em; + margin-inline: 0.2em; + padding: 0.5em; + border: 2px solid gray; + cursor: pointer; +`; + +const getCategoryName = (row: IRow, field: IField) => { + if (field.semanticType === "quantitative") { + let id = field.fid; + return `${row[id][0].toFixed(2)}-${row[id][1].toFixed(2)}`; + } else { + return row[field.fid]; + } +} + +const ExplainData: React.FC<{ + dark: IDarkMode, + themeKey: IThemeKey +}> = observer(({dark, themeKey}) => { + const { vizStore, commonStore } = useGlobalStore(); + const { allFields, viewMeasures, viewDimensions, viewFilters, computationFunction } = vizStore; + const { showInsightBoard, selectedMarkObject } = commonStore; + const [ explainDataInfoList, setExplainDataInfoList ] = useState<{ + score: number; + measureField: IField; + targetField: IField; + normalizedData: IRow[]; + normalizedParentData: IRow[] + }[]>([]); + const [ selectedInfoIndex, setSelectedInfoIndex ] = useState(0); + const chartRef = useRef(null); + + const vegaConfig = useMemo(() => { + const config: VegaGlobalConfig = { + ...builtInThemes[themeKey ?? 'vega']?.[dark], + } + return config; + }, [themeKey, dark]) + + const { t } = useTranslation(); + + const explain = async (predicates) => { + const explainInfoList = await explainBySelection({predicates, viewFilters, allFields, viewMeasures, viewDimensions, computationFunction}); + setExplainDataInfoList(explainInfoList); + } + + useEffect(() => { + if (!showInsightBoard || Object.keys(selectedMarkObject).length === 0) return; + const predicates: IPredicate[] = viewDimensions.map((field) => { + return { + key: field.fid, + type: "discrete", + range: new Set([selectedMarkObject[field.fid]]) + } as IPredicate + }); + explain(predicates) + }, [viewMeasures, viewDimensions, showInsightBoard, selectedMarkObject]); + + useEffect(() => { + if (chartRef.current && explainDataInfoList.length > 0) { + const { normalizedData, normalizedParentData, targetField, measureField } = explainDataInfoList[selectedInfoIndex]; + const { semanticType: targetType, name: targetName, fid: targetId } = targetField; + const data = [ + ...normalizedData.map((row) => ({ + category: getCategoryName(row, targetField), + ...row, + type: "child", + })), + ...normalizedParentData.map((row) => ({ + category: getCategoryName(row, targetField), + ...row, + type: "parent", + })), + ]; + const xField = { + x: { + field: "category", + type: targetType === "quantitative" ? "ordinal" : targetType, + axis: { + title: `Distribution of Values for ${targetName}`, + }, + }, + }; + const spec:any = { + data: { + values: data, + }, + width: 320, + height: 200, + encoding: { + ...xField, + color: { + legend: { + orient: "bottom", + }, + }, + }, + layer: [ + { + mark: { + type: "bar", + width: 15, + opacity: 0.7, + }, + encoding: { + y: { + field: getMeaAggKey(measureField.fid, measureField.aggName), + type: "quantitative", + title: `${measureField.aggName} ${measureField.name} for All Marks`, + }, + color: { datum: "All Marks" }, + }, + transform: [{ filter: "datum.type === 'parent'" }], + }, + { + mark: { + type: "bar", + width: 10, + opacity: 0.7, + }, + encoding: { + y: { + field: getMeaAggKey(measureField.fid, measureField.aggName), + type: "quantitative", + title: `${measureField.aggName} ${measureField.name} for Selected Mark`, + }, + color: { datum: "Selected Mark" }, + }, + transform: [{ filter: "datum.type === 'child'" }], + }, + ], + resolve: { scale: { y: "independent" } }, + }; + + embed(chartRef.current, spec, { mode: 'vega-lite', actions: false, config: vegaConfig }); + } + }, [explainDataInfoList, chartRef.current, selectedInfoIndex, vegaConfig]); + + return ( + { + commonStore.setShowInsightBoard(false); + setSelectedInfoIndex(0); + }} + > + + + { + explainDataInfoList.map((option, i) => { + return ( + setSelectedInfoIndex(i)} + > + {option.targetField.name} {option.score.toFixed(2)} + + ) + }) + } + +
+
+
+
+
+ ); +}); + +export default ExplainData; diff --git a/packages/graphic-walker/src/components/leafletRenderer/ChoroplethRenderer.tsx b/packages/graphic-walker/src/components/leafletRenderer/ChoroplethRenderer.tsx index 3157c71fd..86162080a 100644 --- a/packages/graphic-walker/src/components/leafletRenderer/ChoroplethRenderer.tsx +++ b/packages/graphic-walker/src/components/leafletRenderer/ChoroplethRenderer.tsx @@ -1,13 +1,15 @@ import React, { Fragment, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react"; -import { CircleMarker, MapContainer, Polygon, Marker, TileLayer, Tooltip } from "react-leaflet"; +import { CircleMarker, MapContainer, Polygon, Marker, TileLayer, Tooltip, AttributionControl } from "react-leaflet"; import { type Map, divIcon } from "leaflet"; -import type { DeepReadonly, IRow, IViewField, VegaGlobalConfig } from "../../interfaces"; +import type { DeepReadonly, IGeoUrl, IRow, IViewField, VegaGlobalConfig } from "../../interfaces"; import type { FeatureCollection, Geometry } from "geojson"; import { getMeaAggKey } from "../../utils"; import { useColorScale, useOpacityScale } from "./encodings"; import { isValidLatLng } from "./POIRenderer"; import { TooltipContent } from "./tooltip"; import { useAppRootContext } from "../appRoot"; +import { useGeoJSON } from "../../hooks/service"; +import { useTranslation } from "react-i18next"; export interface IChoroplethRendererProps { @@ -15,6 +17,7 @@ export interface IChoroplethRendererProps { data: IRow[]; allFields: DeepReadonly; features: FeatureCollection | undefined; + featuresUrl?: IGeoUrl; geoKey: string; defaultAggregated: boolean; geoId: DeepReadonly; @@ -88,10 +91,13 @@ const resolveCenter = (coordinates: [lat: number, lng: number][]): [lng: number, }; const ChoroplethRenderer = forwardRef(function ChoroplethRenderer (props, ref) { - const { name, data, allFields, features, geoKey, defaultAggregated, geoId, color, opacity, text, details, vegaConfig, scaleIncludeUnmatchedChoropleth } = props; + const { name, data, allFields, features: localFeatures, featuresUrl, geoKey, defaultAggregated, geoId, color, opacity, text, details, vegaConfig, scaleIncludeUnmatchedChoropleth } = props; useImperativeHandle(ref, () => ({})); + const features = useGeoJSON(localFeatures, featuresUrl) + const { t } = useTranslation('translation'); + const geoIndices = useMemo(() => { if (geoId) { return data.map(row => row[geoId.fid]); @@ -214,12 +220,17 @@ const ChoroplethRenderer = forwardRef{t('main.tabpanel.settings.geography_settings.loading')}
+ } + return ( - + + {lngLat.length > 0 && data.map((row, i) => { const coords = lngLat[i]; const opacity = opacityScale(row); diff --git a/packages/graphic-walker/src/components/leafletRenderer/POIRenderer.tsx b/packages/graphic-walker/src/components/leafletRenderer/POIRenderer.tsx index e972c2cb8..cb0b0e75e 100644 --- a/packages/graphic-walker/src/components/leafletRenderer/POIRenderer.tsx +++ b/packages/graphic-walker/src/components/leafletRenderer/POIRenderer.tsx @@ -1,5 +1,5 @@ import React, { forwardRef, useEffect, useMemo, useRef } from "react"; -import { MapContainer, TileLayer, Tooltip, CircleMarker } from "react-leaflet"; +import { MapContainer, TileLayer, Tooltip, CircleMarker, AttributionControl } from "react-leaflet"; import type { Map } from "leaflet"; import type { DeepReadonly, IRow, IViewField, VegaGlobalConfig } from "../../interfaces"; import { getMeaAggKey } from "../../utils"; @@ -137,11 +137,12 @@ const POIRenderer = forwardRef(function POIR }, [defaultAggregated, details, size, color, opacity]); return ( - + + {Boolean(latitude && longitude) && data.map((row, i) => { const lat = row[latitude!.fid]; const lng = row[longitude!.fid]; diff --git a/packages/graphic-walker/src/components/leafletRenderer/geoConfigPanel.tsx b/packages/graphic-walker/src/components/leafletRenderer/geoConfigPanel.tsx index 57ea6c5c0..aa3cc1e92 100644 --- a/packages/graphic-walker/src/components/leafletRenderer/geoConfigPanel.tsx +++ b/packages/graphic-walker/src/components/leafletRenderer/geoConfigPanel.tsx @@ -1,27 +1,60 @@ import { observer } from 'mobx-react-lite'; import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { runInAction } from 'mobx'; +import Spinner from '../spinner'; import { useGlobalStore } from '../../store'; import Modal from '../modal'; import PrimaryButton from '../button/primary'; import DefaultButton from '../button/default'; -import type { Topology } from '../../interfaces'; +import type { IGeoDataItem, IGeoUrl, Topology } from '../../interfaces'; +import DropdownSelect from '../dropdownSelect'; +import Dropzone from 'react-dropzone'; +import { GeojsonRenderer } from './geojsonRenderer'; -const GeoConfigPanel: React.FC = (props) => { +const emptyList = []; + +const GeoConfigPanel = ({ geoList = emptyList }: { geoList?: IGeoDataItem[] }) => { const { commonStore, vizStore } = useGlobalStore(); const { showGeoJSONConfigPanel } = commonStore; const { visualConfig } = vizStore; - const { geoKey, geojson } = visualConfig; + const { geoKey, geojson, geoUrl } = visualConfig; const { t: tGlobal } = useTranslation('translation'); const { t } = useTranslation('translation', { keyPrefix: 'main.tabpanel.settings' }); - const [dataMode, setDataMode] = useState<'GeoJSON' | 'TopoJSON'>('GeoJSON'); + const [dataMode, setDataMode] = useState<'GeoJSON' | 'TopoJSON'>(geoUrl?.type ?? 'GeoJSON'); const [featureId, setFeatureId] = useState(''); - const [url, setUrl] = useState(''); + const [url, setUrl] = useState(geoUrl?.url ?? ''); const [geoJSON, setGeoJSON] = useState(''); const [topoJSON, setTopoJSON] = useState(''); const [topoJSONKey, setTopoJSONKey] = useState(''); + const [loadedUrl, setLoadedUrl] = useState(geoUrl); + const [loading, setLoading] = useState(false); + + const hasCustomData = url || geojson; + + const [selectItem, setSelectItemR] = useState(() => { + const i = geoList.findIndex((x) => x.url === geoUrl?.url && x.type === geoUrl?.type); + if (i === -1 && hasCustomData) { + return -2; + } + return i; + }); + + const options = useMemo( + () => + [{ label: 'Select a Geographic Data', value: '-1' }] + .concat( + geoList.map((x, i) => ({ + label: x.name, + value: `${i}`, + })) + ) + .concat({ label: 'Manual Set', value: '-2' }), + [geoList] + ); + const setSelectItem = useMemo(() => (a: string) => setSelectItemR(parseInt(a)), []); + + const isCustom = (geoList ?? []).length === 0 || selectItem === -2; const defaultTopoJSONKey = useMemo(() => { try { @@ -40,13 +73,54 @@ const GeoConfigPanel: React.FC = (props) => { setGeoJSON(geojson ? JSON.stringify(geojson, null, 2) : ''); }, [geojson]); - return ( - { + const handleSubmit = () => { + if (!isCustom) { + const item = geoList[selectItem]; + if (!item) { + vizStore.clearGeographicData(); + } else { + vizStore.setGeographicUrl({ + type: item.type, + url: item.url, + }); + } + commonStore.setShowGeoJSONConfigPanel(false); + return; + } + try { + if (!(dataMode === 'GeoJSON' ? geoJSON : topoJSON) && loadedUrl) { commonStore.setShowGeoJSONConfigPanel(false); - }} - > + return; + } + const json = JSON.parse(dataMode === 'GeoJSON' ? geoJSON : topoJSON); + if (dataMode === 'TopoJSON') { + vizStore.setGeographicData( + { + type: 'TopoJSON', + data: json, + objectKey: topoJSONKey || defaultTopoJSONKey, + }, + featureId, + loadedUrl?.type === 'TopoJSON' ? loadedUrl : undefined + ); + } else { + vizStore.setGeographicData( + { + type: 'GeoJSON', + data: json, + }, + featureId, + loadedUrl?.type === 'GeoJSON' ? loadedUrl : undefined + ); + } + commonStore.setShowGeoJSONConfigPanel(false); + } catch (err) { + console.error(err); + } + }; + + return ( + commonStore.setShowGeoJSONConfigPanel(false)}>

{t('geography')}

@@ -57,130 +131,136 @@ const GeoConfigPanel: React.FC = (props) => { type="text" className="block w-full rounded-md border-0 py-1 px-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" value={featureId} - onChange={(e) => { - setFeatureId(e.target.value); - }} + onChange={(e) => setFeatureId(e.target.value)} />
-
- -
-
-
- { - setDataMode('GeoJSON'); - }} - /> - - { - setDataMode('TopoJSON'); - }} - /> - + {geoList.length > 0 && ( +
+ + +
+ )} + {isCustom && ( +
+ +
+
+
+ { + setDataMode('GeoJSON'); + }} + /> + + { + setDataMode('TopoJSON'); + }} + /> + +
-
-
- - { - setUrl(e.target.value); - }} - /> - { - if (url) { - fetch(url) - .then((res) => res.json()) - .then((json) => { - (dataMode === 'GeoJSON' ? setGeoJSON : setTopoJSON)( - JSON.stringify(json, null, 2) - ); - }); - } - }} - /> -
-