Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 98 additions & 5 deletions js/panes/EmbeddingsPane.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,21 @@ import Pane from './Pane';
const SCALE_RADIUS = 2000;
const MIN_SELECTION = 22;

const EXPORT_FORMATS = ['png', 'jpg'];

class EmbeddingsPane extends React.Component {
shouldComponentUpdate(nextProps) {
state = { exportError: null };
sceneRef = React.createRef();

shouldComponentUpdate(nextProps, nextState) {
if (this.props.contentID !== nextProps.contentID) return true;
if (
Math.round(this.props.height) !== Math.round(nextProps.height) ||
Math.round(this.props.width) !== Math.round(nextProps.width)
)
return true;
if (this.props.isFocused !== nextProps.isFocused) return true;
if (this.state.exportError !== nextState.exportError) return true;
return false;
}

Expand Down Expand Up @@ -109,7 +115,7 @@ class EmbeddingsPane extends React.Component {
EventSystem.unsubscribe('global.event', this.onEvent);
}

handleDownload = () => {
handleMetadataExport = () => {
var blob = new Blob([JSON.stringify(this.props.content.data)], {
type: 'text/plain',
});
Expand All @@ -122,9 +128,93 @@ class EmbeddingsPane extends React.Component {
link.click();
};

handleExport = (format, dpi) => {
this.setState({ exportError: null });

const sceneInstance = this.sceneRef.current;
if (!sceneInstance) {
this.setState({
exportError: 'Visualization is not ready yet. Please try again.',
});
return;
}

const { renderer, scene, camera } = sceneInstance;
if (!renderer || !scene || !camera) {
this.setState({
exportError: 'Visualization is not ready yet. Please try again.',
});
return;
}

const width = Math.max(1, this.props.width);
const height = Math.max(1, this.props.height);
const scale = dpi ? dpi / 96 : 1;
const originalPixelRatio = renderer.getPixelRatio();

try {
// `updateStyle=false` keeps the on-screen CSS size unchanged while
// only the internal render resolution increases
renderer.setPixelRatio(originalPixelRatio * scale);
renderer.setSize(width, height, false);
renderer.render(scene, camera);

const mime = format === 'jpg' ? 'image/jpeg' : 'image/png';
const dataUrl = renderer.domElement.toDataURL(
mime,
format === 'jpg' ? 0.92 : undefined
);

const link = document.createElement('a');
link.download = `${this.props.contentID || 'plot'}.${format}`;
link.href = dataUrl;
link.click();
} catch (err) {
// eslint-disable-next-line no-console
console.error('EmbeddingsPane export failed:', err);
this.setState({
exportError: 'Export failed: ' + (err.message || 'unknown error'),
});
} finally {
// restore the on-screen resolution and schedule a normal repaint,
// regardless of whether the export above succeeded
renderer.setPixelRatio(originalPixelRatio);
renderer.setSize(width, height, false);
sceneInstance.scheduleRender();
}
};

render() {
return (
<Pane {...this.props} handleDownload={this.handleDownload}>
<Pane
{...this.props}
handleExport={this.handleExport}
handleMetadataExport={this.handleMetadataExport}
exportFormats={EXPORT_FORMATS}
>
{this.state.exportError ? (
<div
style={{
position: 'absolute',
top: 16,
left: 0,
right: 0,
textAlign: 'center',
zIndex: 5,
}}
>
<span
style={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
color: '#c00',
padding: 2,
userSelect: 'none',
}}
>
{this.state.exportError}
</span>
</div>
) : null}
{this.props.content.isLoading ? (
<div
style={{
Expand All @@ -141,6 +231,7 @@ class EmbeddingsPane extends React.Component {
</div>
) : (
<Scene
ref={this.sceneRef}
key={
this.props.height +
'===' +
Expand Down Expand Up @@ -311,7 +402,9 @@ class Scene extends React.Component {
'#cab2d6',
'#cccc00',
];
let circle_sprite = new THREE.TextureLoader().load(
const textureLoader = new THREE.TextureLoader();
textureLoader.setCrossOrigin('anonymous');
let circle_sprite = textureLoader.load(
'https://fastforwardlabs.github.io/visualization_assets/circle-sprite.png',
() => this.scheduleRender()
);
Expand Down Expand Up @@ -359,7 +452,7 @@ class Scene extends React.Component {
});

let points = new THREE.Points(pointsGeometry, pointsMaterial);
let renderer = new THREE.WebGLRenderer();
let renderer = new THREE.WebGLRenderer({ preserveDrawingBuffer: true });

let scene = new THREE.Scene();
scene.add(points);
Expand Down
53 changes: 49 additions & 4 deletions js/panes/NetworkPane.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function NetworkPane(props) {

// private events
// --------------
const handleDownload = () => {
const handleExport = (format, dpi) => {
const svg = containerRef.current?.querySelector('svg');

if (!svg) {
Expand All @@ -45,15 +45,60 @@ function NetworkPane(props) {
return;
}

const filename = `${props.contentID || 'plot'}.${format}`;

const scale = dpi ? dpi / 96 : 2;

requestAnimationFrame(() => {
const filename = props.contentID ? `${props.contentID}.png` : 'plot.png';
if (format === 'svg') {
const clone = svg.cloneNode(true);
inlineComputedStyles(svg, clone);
const source = new XMLSerializer().serializeToString(clone);
const blob = new Blob([source], {
type: 'image/svg+xml;charset=utf-8',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => window.URL.revokeObjectURL(url), 1000);
return;
}

saveSvgAsPng(svg, filename, {
scale: 2,
scale,
backgroundColor: '#FFFFFF',
encoderType: format === 'jpg' ? 'image/jpeg' : 'image/png',
encoderOptions: format === 'jpg' ? 0.92 : undefined,
});
});
};

const SVG_STYLE_PROPS = [
'fill',
'stroke',
'stroke-width',
'stroke-opacity',
'fill-opacity',
'opacity',
'font-family',
'font-size',
];
const inlineComputedStyles = (liveEl, cloneEl) => {
const computed = window.getComputedStyle(liveEl);
const styleStr = SVG_STYLE_PROPS.map(
(prop) => `${prop}:${computed.getPropertyValue(prop)}`
).join(';');
cloneEl.setAttribute('style', styleStr);

for (let i = 0; i < liveEl.children.length; i++) {
inlineComputedStyles(liveEl.children[i], cloneEl.children[i]);
}
};

// effects
// -------

Expand Down Expand Up @@ -253,7 +298,7 @@ function NetworkPane(props) {
// ---------

return (
<Pane {...props} handleDownload={handleDownload}>
<Pane {...props} handleExport={handleExport}>
{downloadError && <div className="error-message">{downloadError}</div>}
<div
ref={containerRef}
Expand Down
20 changes: 14 additions & 6 deletions js/panes/Pane.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import React, {

import ApiContext from '../api/ApiContext';
import PropertyItem from './PropertyItem';
import ExportPane from './utils/ExportPane';
var classNames = require('classnames');

const COMMENT_SAVE_DEBOUNCE_MS = 500;
Expand Down Expand Up @@ -86,8 +87,8 @@ var Pane = forwardRef((props, ref) => {
let barClassNames = classNames({ bar: true, focus: props.isFocused });

let contentClassNames = classNames({
content: true,
'content-with-comment': commentEnabled && commentOpen,
content: true,
'content-with-comment': commentEnabled && commentOpen,
});

// add property list button to barwidgets
Expand Down Expand Up @@ -166,10 +167,17 @@ var Pane = forwardRef((props, ref) => {
{' '}
X{' '}
</button>
<button title="save" onClick={handleDownload}>
{' '}
&#8681;{' '}
</button>
{props.handleExport ? (
<ExportPane
onExport={props.handleExport}
allowedFormats={props.exportFormats}
/>
) : (
<button title="save" onClick={handleDownload}>
{' '}
&#8681;{' '}
</button>
)}
<button
title="export metadata"
onClick={handleMetadataExport}
Expand Down
10 changes: 7 additions & 3 deletions js/panes/PlotPane.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@ var PlotPane = (props) => {
const updateSmoothSlider = (value) => {
setSmoothValue(value);
};
const handleDownload = () => {

const dpiToScale = (dpi) => (dpi ? dpi / 96 : 1);

const handleExport = (format, dpi) => {
Plotly.downloadImage(plotlyRef.current, {
format: 'svg',
format: format === 'jpg' ? 'jpeg' : format,
scale: dpiToScale(dpi),
filename: contentID || 'plot',
});
};
Expand Down Expand Up @@ -346,7 +350,7 @@ var PlotPane = (props) => {
return (
<Pane
{...props}
handleDownload={handleDownload}
handleExport={handleExport}
handleMetadataExport={handleMetadataExport}
barwidgets={[smooth_widget_button]}
widgets={[history_widget, caption_widget, smooth_widget]}
Expand Down
Loading
Loading