Skip to content

Commit

Permalink
Merge pull request #739 from silx-kit/matrix-csv-export
Browse files Browse the repository at this point in the history
Implement CSV export for Matrix vis
  • Loading branch information
axelboc committed Jul 7, 2021
2 parents f458072 + eb09645 commit 3e0350f
Show file tree
Hide file tree
Showing 15 changed files with 220 additions and 87 deletions.
32 changes: 32 additions & 0 deletions src/h5web/toolbar/MatrixToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { FiDownload } from 'react-icons/fi';
import { useMatrixConfig } from '../vis-packs/core/matrix/config';
import { sliceToCsv } from '../vis-packs/core/matrix/utils';
import DownloadBtn from './controls/DownloadBtn';
import Toolbar from './Toolbar';

function MatrixToolbar() {
const currentSlice = useMatrixConfig((state) => state.currentSlice);
if (currentSlice && currentSlice.shape.length > 2) {
throw new Error('Expected current slice to have at most two dimensions');
}

return (
<Toolbar>
{currentSlice && (
<DownloadBtn
icon={FiDownload}
label="CSV"
filename="export.csv"
getDownloadUrl={() => {
const data = sliceToCsv(currentSlice);
return URL.createObjectURL(
new Blob([data], { type: 'text/csv;charset=utf-8' })
);
}}
/>
)}
</Toolbar>
);
}

export default MatrixToolbar;
5 changes: 5 additions & 0 deletions src/h5web/toolbar/Toolbar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
padding: 0 0.25rem;
}

a.btn {
color: inherit;
text-decoration: none;
}

.btn[data-small] {
padding: 0 0.125rem;
}
Expand Down
2 changes: 1 addition & 1 deletion src/h5web/toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import OverflowMenu from './OverflowMenu';
import MeasuredControl from './MeasuredControl';

interface Props {
children?: (ReactElement | undefined)[];
children?: ReactElement | (ReactElement | undefined)[];
}

function Toolbar(props: Props) {
Expand Down
47 changes: 47 additions & 0 deletions src/h5web/toolbar/controls/Btn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { AriaAttributes } from 'react';
import type { IconType } from 'react-icons';
import styles from '../Toolbar.module.css';

interface Props extends AriaAttributes {
label: string;
icon?: IconType;
iconOnly?: boolean;
small?: boolean;
raised?: boolean;
onClick: () => void;
disabled?: boolean;
}

function Btn(props: Props) {
const {
label,
icon: Icon,
iconOnly,
small,
raised,
disabled,
onClick,
...ariaAttrs
} = props;

return (
<button
className={styles.btn}
type="button"
onClick={() => onClick()}
disabled={disabled}
data-small={small || undefined}
data-raised={raised || undefined}
aria-label={iconOnly ? label : undefined}
{...ariaAttrs}
>
<span className={styles.btnLike}>
{Icon && <Icon className={styles.icon} />}
{!iconOnly && <span className={styles.label}>{label}</span>}
</span>
</button>
);
}

export type { Props as BtnProps };
export default Btn;
52 changes: 52 additions & 0 deletions src/h5web/toolbar/controls/DownloadBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { AriaAttributes } from 'react';
import type { IconType } from 'react-icons/lib';
import styles from '../Toolbar.module.css';

interface Props extends AriaAttributes {
label: string;
icon?: IconType;
iconOnly?: boolean;
// If specified, browser will prompt user to download the file instead of opening a new tab
filename?: string;
// Invoked on click; must return URL to set as `href`, or `false` to do nothing
getDownloadUrl: () => string | false;
}

function DownloadBtn(props: Props) {
const {
label,
icon: Icon,
iconOnly,
filename,
getDownloadUrl,
...ariaAttrs
} = props;

return (
<a
className={styles.btn}
href="/" // placeholder replaced dynamically on click
target="_blank"
download={filename}
aria-label={iconOnly ? label : undefined}
{...ariaAttrs}
onClick={(evt) => {
const url = getDownloadUrl();

if (url === false) {
evt.preventDefault();
return;
}

evt.currentTarget.setAttribute('href', url);
}}
>
<span className={styles.btnLike}>
{Icon && <Icon className={styles.icon} />}
{!iconOnly && <span className={styles.label}>{label}</span>}
</span>
</a>
);
}

export default DownloadBtn;
21 changes: 0 additions & 21 deletions src/h5web/toolbar/controls/SnapshotButton.module.css

This file was deleted.

33 changes: 9 additions & 24 deletions src/h5web/toolbar/controls/SnapshotButton.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
import { MdCameraAlt } from 'react-icons/md';
import styles from './SnapshotButton.module.css';
import DownloadBtn from './DownloadBtn';

function SnapshotButton() {
return (
<a
className={styles.link}
href="/"
target="_blank"
aria-label="Snapshot"
onClick={(evt) => {
<DownloadBtn
label="Snapshot"
icon={MdCameraAlt}
filename="snapshot"
getDownloadUrl={() => {
// Create data URL from canvas (if rendered)
const canvas = document.querySelector('canvas');

// Create data URL from canvas
const screnshotUrl = canvas?.toDataURL();

if (screnshotUrl) {
// Let link open screenshot URL in new tab/window
evt.currentTarget.setAttribute('href', screnshotUrl);
} else {
// Don't follow link if canvas hasn't been rendered yet
evt.preventDefault();
}
return canvas?.toDataURL() || false;
}}
>
<span className={styles.btnLike}>
<MdCameraAlt className={styles.icon} />
<span className={styles.label}>Snapshot</span>
</span>
</a>
/>
);
}

Expand Down
44 changes: 4 additions & 40 deletions src/h5web/toolbar/controls/ToggleBtn.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,13 @@
import type { AriaAttributes } from 'react';
import type { IconType } from 'react-icons';
import styles from '../Toolbar.module.css';
import Btn, { BtnProps } from './Btn';

interface Props extends AriaAttributes {
label: string;
icon?: IconType;
iconOnly?: boolean;
small?: boolean;
raised?: boolean;
interface Props extends Omit<BtnProps, 'onClick'> {
value: boolean;
onToggle: () => void;
disabled?: boolean;
}

function ToggleBtn(props: Props) {
const {
label,
icon: Icon,
iconOnly,
small,
raised,
value,
onToggle,
disabled,
...ariaAttrs
} = props;

return (
<button
className={styles.btn}
type="button"
onClick={() => onToggle()}
disabled={disabled}
data-small={small || undefined}
data-raised={raised || undefined}
aria-label={iconOnly ? label : undefined}
aria-pressed={value}
{...ariaAttrs}
>
<span className={styles.btnLike}>
{Icon && <Icon className={styles.icon} />}
{!iconOnly && <span className={styles.label}>{label}</span>}
</span>
</button>
);
const { value, onToggle, ...btnProps } = props;
return <Btn {...btnProps} aria-pressed={value} onClick={() => onToggle()} />;
}

export default ToggleBtn;
1 change: 1 addition & 0 deletions src/h5web/toolbar/toolbars.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as MatrixToolbar } from './MatrixToolbar';
export { default as LineToolbar } from './LineToolbar';
export { default as HeatmapToolbar } from './HeatmapToolbar';
export { default as ComplexToolbar } from './ComplexToolbar';
Expand Down
1 change: 1 addition & 0 deletions src/h5web/vis-packs/core/configs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { MatrixConfigProvider } from './matrix/config';
export { LineConfigProvider } from './line/config';
export { HeatmapConfigProvider } from './heatmap/config';
export { ComplexConfigProvider } from './complex/config';
Expand Down
9 changes: 8 additions & 1 deletion src/h5web/vis-packs/core/matrix/MappedMatrixVis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { DimensionMapping } from '../../../dimension-mapper/models';
import type { Primitive } from '../../../providers/models';
import { useMappedArray, useSlicedDimsAndMapping } from '../hooks';
import type { PrintableType } from '../models';
import { useMatrixConfig } from './config';
import { useEffect } from 'react';

interface Props {
value: Primitive<PrintableType>[];
Expand All @@ -16,9 +18,14 @@ function MappedMatrixVis(props: Props) {
const { value, dims, dimMapping, formatter, cellWidth } = props;

const [slicedDims, slicedMapping] = useSlicedDimsAndMapping(dims, dimMapping);

const [mappedArray] = useMappedArray(value, slicedDims, slicedMapping);

const setCurrentSlice = useMatrixConfig((state) => state.setCurrentSlice);

useEffect(() => {
setCurrentSlice(mappedArray);
}, [mappedArray, setCurrentSlice]);

return (
<MatrixVis
dataArray={mappedArray}
Expand Down
26 changes: 26 additions & 0 deletions src/h5web/vis-packs/core/matrix/config.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { NdArray } from 'ndarray';
import create from 'zustand';
import createContext from 'zustand/context';
import type { Primitive } from '../../../providers/models';
import type { ConfigProviderProps } from '../../models';
import type { PrintableType } from '../models';

interface MatrixConfig {
currentSlice: NdArray<Primitive<PrintableType>> | undefined;
setCurrentSlice: (slice: NdArray<Primitive<PrintableType>>) => void;
}

function createStore() {
return create<MatrixConfig>((set) => ({
currentSlice: undefined,
setCurrentSlice: (slice) => set({ currentSlice: slice }),
}));
}

const { Provider, useStore } = createContext<MatrixConfig>();
export const useMatrixConfig = useStore;

export function MatrixConfigProvider(props: ConfigProviderProps) {
const { children } = props;
return <Provider createStore={createStore}>{children}</Provider>;
}
28 changes: 28 additions & 0 deletions src/h5web/vis-packs/core/matrix/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { NdArray } from 'ndarray';
import { hasComplexType, hasNumericType } from '../../../guards';
import { renderComplex } from '../../../metadata-viewer/utils';
import type {
ArrayShape,
Dataset,
H5WebComplex,
Primitive,
} from '../../../providers/models';
import type { PrintableType, ValueFormatter } from '../models';
import { formatNumber } from '../utils';
Expand All @@ -21,3 +23,29 @@ export function getFormatter(

return (val) => (val as string).toString();
}

export function sliceToCsv(slice: NdArray<Primitive<PrintableType>>): string {
let csv = '';

if (slice.shape.length === 1) {
for (let i = 0; i < slice.shape[0]; i++) {
csv += `${slice.get(i).toString()}\n`; // complex numbers are stringifyied as two values
}

return csv;
}

if (slice.shape.length === 2) {
for (let i = 0; i < slice.shape[0]; i++) {
let line = '';
for (let j = 0; j < slice.shape[1]; j++) {
line += `${slice.get(i, j).toString()},`; // complex numbers are stringifyied as two values
}
csv += `${line.replace(/,$/u, '\n')}`;
}

return csv;
}

throw new Error('Expected at most 2 dimensions');
}

0 comments on commit 3e0350f

Please sign in to comment.