Svelte and D3 brush - d3.js

I'm struggling to understand how to use Svelte with something like D3's brush project. Svelte operates using a declarative approach. In the area chart example the SVG for lines is written out in the template HTML. To do this with D3 you would use Javascript function calls to select an element and call another function to modify the DOM. In the aforementioned chart example the D3 scaled library is only used to generate the axis array but the HTML itself is managed by Svelte. It makes sense Svelte works this way - building things up with function calls would be a lot less clean, but I can't figure out how to do this with the brush. How can I declaratively build up the brush HTML inside of my Svelte template, and how would this affect things like brush events? Would it rather be best to just use the brush functions inside of say onMount and sort-of tie change events to local Svelte variables?

The same problem exists in React, because both React and D3 want to be in charge of the DOM. In React you simply call the a function that instructs D3 to do it's work in the ComponentDidMount method (or a useEffect if using hooks.
Svelte expects to be in charge of the situation, you declare how the UI is constructed, and define the operations, leaving it to do the work. It won't be able to track what D3 does, so I suspect you need to just let D3 be in charge of that part, and not worry about it being a little bit hacky.

I managed to do this myself https://svelte.dev/repl/00f726facd434b978c737af2698e0dbc?version=3.12.1
As Mikkel said above, the way Svelte is designed doesn't play well naturally with something like D3. As I see it you have two options: try to wire D3 events into Svelte's reactive variables, or try to implement the functionality yourself.
I opted for the second version. I took the HTML and CSS that D3 Brush created, adding a mouse handler to the carets, and tied all the variables together reactively. (The last part I did very messily. Would appreciate any feedback on doing this cleaner from other Svelte users).

It also took me a bit to wrap my head around this. But in the end it's actually not that complicated. They key step is to decide which library takes care of which responsibility.
D3 simply overlaps with Svelte since it also renders things on the screen. But if you think about it, you don't really need a renderer if you already have Svelte. Once you have a renderer, the complicated part about charts is really the positioning. And this is where D3 really shines. If you "cherry pick" the best from both worlds you actually end up with a great dev experience.
But, alas, you can also leave the rendering to D3. But then you need to keep Svelte out of the picture as much as possible.
Essentially you have two options that both work well:
Svelte renders only the container DOM and then hands over to D3 to both calculate & render. You interface between both worlds only during onMount and onDestroy
Svelte renders the whole DOM, D3 provides the chart positions.
As for the brush functionality:
I found it to work best to create a ChartContainer (which is essentially just an SVG) with slots and then drop a Brush component inside.
<script>
import { createEventDispatcher } from "svelte";
export let minX;
export let maxX;
export let dX = 0;
export let height;
const dispatch = createEventDispatcher();
let startX,
endX,
mouseDown = false,
brushArea;
function onMouseDown(event) {
if (mouseDown) return;
mouseDown = true;
brushArea.removeEventListener("mousemove", onMouseMove);
brushArea.removeEventListener("mouseup", onMouseUp);
brushArea.addEventListener("mousemove", onMouseMove);
brushArea.addEventListener("mouseup", onMouseUp);
brushArea.style.cursor = "ew-resize";
startX = Math.max(event.offsetX - dX, minX);
endX = null;
}
function onMouseMove(event) {
endX = Math.min(event.offsetX - dX, maxX);
}
function onMouseUp(event) {
mouseDown = false;
if (!endX) startX = null;
brushArea.style.cursor = null;
brushArea.removeEventListener("mousemove", onMouseMove);
brushArea.removeEventListener("mouseup", onMouseUp);
const active = !!startX;
dispatch("brush", {active, startX, endX, clear});
}
function clear() {
startX = null;
endX = null;
}
</script>
<rect class="ui--chart__brush_area"
bind:this={brushArea}
x={minX}
y="0"
height={height}
width={maxX-minX}
on:mousedown={onMouseDown}
/>
{#if endX != null}
<rect class="ui--chart__brush"
x={startX < endX ? startX : endX}
y="0"
height={height}
width={startX < endX ? endX-startX : startX-endX}
/>
{/if}
The dX prop is used to consider a left margin. Might or might not be required in your use case (depending on how you setup your chart). The key thing is to be able to use offsetX from the mouse event so that you know how far off from the SVG borders your mouse events have fired.
Then, you just
listen for the brush events
extract the coordinates
convert them to values -by using yourScale.invert(coordinate)
use these values, e.g. to update your chart
Like so:
function onBrush(event) {
const {active, startX, endX, clear} = event.detail;
if (active) {
const startDate = scaleX.invert(startX);
const endDate = scaleX.invert(endX);
dispatch("brush", {active, startX, endX, startDate, endDate, clear});
}
}
Hope this helps anyone else struggling with this. Good luck!

There isn't a single answer to your question, but I think the best option is to render the "most relevant data" using svelte's html, leaving the interactive elements (like brush) to run only on client side.
You should known that svelte converts your html to js generators internally, so calling the d3 functions is actually pretty similar to what svelte does client side. The only real advantage of using the svelte's html instead of d3 functions calls is SSR, and that's the reason it's reasonable to leave the brush to be client side only (since it needs js to be interactive anyway).
Svelte is kind of a "reactive vanilla", so you can use low-level libraries almost directly. Sometimes you need to do some tricks to access DOM elements directly (like d3 usually does), for that I recommend using the bind:this directive. Example:
<script>
import { brushX, select } from 'd3';
//...
let brushElement;
$: brush = brushX()
.extent([[padding.left, padding.top], [width - padding.right, height - padding.bottom]])
.on('end', onZoom)
$: if (brushElement) {
select(brushElement)
.call(brush)
}
</script>
<svg>
...
<g bind:this={brushElement} width={...} height={...} />
...
</svg>
One thing to consider when using DOM's API is SSR (sapper), thus any call of d3's select should be done only in the browser. The if case in the code above takes care of that, because the bind:this directive will only set brushElement when running client side.

Related

Plotly.js, show tooltips outside of chart container

I need to implement a plotly.js chart on a page with a very restricted width. As a result, a tooltip is partially cut. Is it possible to cause tooltip not to be limited by plotly.js container size?
My code example at codepen: https://codepen.io/anatoly314/pen/gOavXzZ?editors=1111
//my single trace defined as following but it's better to see example at codepen
const yValue1 = [1000];
const trace1 = {
x: [1],
y: yValue1,
name: `Model 1`,
text: yValue1.map(value => Math.abs(value)),
type: 'bar',
textposition: 'outside'
};
It is, by design, not possible for any part of the chart to overflow its container.
I would say it is wrong to say that by design this is not possible! It is a bit hacky, but when you add the following lines, it shows the label outside of svg:
svg.main-svg,svg.main-svg *
{
overflow:visible !important;
}
The answer given by rokdd works. However the css selector should be more specific, otherwise it's natural that you will introduce subtle bugs (particularly if you need to scroll the content where the plotly chart is contained).
If we look at the DOM tree constructed by Plotly, we find that the tooltips are created inside the <g class="hoverlayer"></g> element (which is a direct child of one of the three <svg class="main-svg"></svg>). So that parent (that svg.main-svg element) is only one that needs to affected.
The ideal css selector in this case would be the :has selector. However it's still not supported (as of 2022): https://css-tricks.com/the-css-has-selector/
So the next simplest thing is to use a little bit of javascript right after we call Plotly.newPlot:
// get the correct svg element
var mainSvgEl = document.querySelector('#positive g.hoverlayer').parentElement;
mainSvgEl.style['overflow'] = 'visible';
Or in a more generic way (works for any chart):
Array.from(document.querySelectorAll('g.hoverlayer')).forEach(hoverEl => {
let mainSvgEl = hoverEl.parentElement;
mainSvgEl.style['overflow'] = 'visible';
});

d3.brush does not work in Firefox or Edge when embedded

I'm using the Swiper API. When there's any d3 Chart embedded, the brush receives senseless mouse coordinates, in any case not relative to the container where the click occurs. (That's in fact at least the surrounded svg.)
I'm trying to find a solution but I don't know how I can force d3.brushX to use mouse coordinates which are really relative.
I don't know whether this is a bug or not, it has probably not really to do with the brush itself, rather how the browser pass mouse clicks top-down the DIV's until the SVG will be reached.
Here's the Fiddle.
(just for the annoying code rule:)
// Add brushing
var brush = d3.brushX()
The second slide contains an embedded d3 line chart example, taken from here.
The fiddle works only in Chrome 75+.
Not in Firefox 68+ nor in Edge 44+.
Running the chart example standalone, it works in all available browsers. So I designate this post for Swiper and D3 hopefully to get a hint for an solution.
According to the problem here I found out, that I can change the behavior in the point.js routines an an workaround.
If a D3 chart makes use of an brush **and ** the SVG of the chart element is embedded by a surrounding DIV with an explicite width, mouse clicks will not be interpreted correctly in Firefox or Edge. In Chrome it works perfectly.
I changed the code like this to achieve that it works in FF and Edge, but lose functionality with Chrome:
function reverseTraversal(node,targetTagName) {
var p = node;
while(p.tagName != targetTagName) p = p.parentNode;
return p;
}
function point(node, event) {
var svg = node.ownerSVGElement || node;
if (svg.createSVGPoint) {
var point = svg.createSVGPoint();
var p = reverseTraversal(node,"DIV");
var rect = p.getBoundingClientRect();
point.x = event.clientX + rect.width, point.y = event.clientY;
point = point.matrixTransform(node.getScreenCTM().inverse());
return [point.x, point.y];
}
var rect = node.getBoundingClientRect();
return [event.clientX - rect.left - node.clientLeft, event.clientY - rect.top - node.clientTop];
}
As you can see, I have to traverse backwards until the closest DIV will be reached, get the bounding and add its width to the clientX coordinate.
Without adding the fixed width, the brush is unusable in the particular case.
To get working with all the browsers, maybe a switch is necessary.
It's not a perfect solution, just a workaround for d3.brushX behavior.

How to apply brushing on a dynamically growing dataset?

I have a dynamically growing timeseries I need to display in a zoomable/panable chart.
Try it out here (in fact: my first jsFiddle ever :) ) :
https://jsfiddle.net/Herkules001/L12k5zwx/29/
I tried to do it the same way as described here: https://dc-js.github.io/dc.js/examples/replacing-data.html
However, each time the chart updates, the zoom and filter are lost on the focus chart. (The brush is preserved on the range chart however.)
How can I add data without resetting the views and losing the zoom?
var chart = dc.lineChart("#test");
var zoom = dc.lineChart("#zoom");
//d3.csv("morley.csv", function(error, experiments) {
var experiments = d3.csvParse(d3.select('pre#data').text());
experiments.forEach(function(x) {
x.Speed = +x.Speed;
});
var ndx = crossfilter(experiments),
runDimension = ndx.dimension(function(d) {return +d.Run;}),
speedSumGroup = runDimension.group().reduceSum(function(d) {return d.Speed * d.Run / 1000;});
chart
.width(768)
.height(400)
.x(d3.scaleLinear().domain([6,20]))
.brushOn(false)
.yAxisLabel("This is the Y Axis!")
.dimension(runDimension)
.group(speedSumGroup)
.rangeChart(zoom);
zoom
.width(768)
.height(80)
.x(d3.scaleLinear().domain([6,20]))
.brushOn(true)
.yAxisLabel("")
.dimension(runDimension)
.group(speedSumGroup);
zoom.render();
chart.render();
var run = 21;
setInterval(
() => {
var chartfilter = chart.filters();
var zoomfilter = zoom.filters();
chart.filter(null);
zoom.filter(null);
ndx.add([{Expt: 6, Run: run++, Speed: 100 + 5 * run}]);
chart.x(d3.scaleLinear().domain([6,run]));
zoom.x(d3.scaleLinear().domain([6,run]));
chart.filter([chartfilter]);
zoom.filter([zoomfilter]);
chart.render();
zoom.render();
},
1000);
//});
In this case, if you are just adding data, you don't need to do the complicated clearing and restoring of filters demonstrated in the example you cited.
That part is only necessary because crossfilter.remove() originally would remove the data that matched the current filters. An awkward interface, almost never what you want.
If you're only adding data, you don't have to worry about any of that:
setInterval(
() => {
ndx.add([{Expt: 6, Run: run++, Speed: 5000 + 5 * run}]);
chart.redraw();
zoom.redraw();
},
5000);
Note that you'll get less flicker, and decent animated transitions, by using redraw instead of render. I also added evadeDomainFilter to avoid lines being clipped before the edge of the chart.
Fork of your fiddle
Removing data
If you use the predicate form of crossfilter.remove() you don't have to worry about saving and restoring filters:
ndx.remove(d => d.Run < run-20);
However, this does expose other bugs in dc.js. Seems like elasticY does not work, similar to what's described in this issue. And you get some weird animations.
Here's a demo with remove enabled.
In the end, dc.js has some pretty neat features, and there is usually a way to get it to do what you want, but it sure is quirky. It's a very complicated domain and in my experience you are going to find some of these quirks in any fully featured charting library.
Update: I fixed the replacing data example, that one is just ndx.remove(() => true) now.
zooming issues
As Joerg pointed out in the comments,
when the chart is not zoomed, it would be nice to have it also grow to show new data as it arrives
the X domain was clipped or even reversed if the focus reached outside the original domain of the chart
We can address these issues by adding a preRedraw event handler. That's the ideal place to adjust the domain; for example you can implement elasticX manually if you need to. (As you'll see in a second, we do!)
First, a naive attempt that's easy to understand:
chart.on('preRedraw', () => {
chart.elasticX(!zoom.filters().length);
});
We can turn elasticX on and off based on whether the range chart has an active filter.
This works and it's nice and simple, but why does the chart get so confused when you try to focus on a domain that wasn't in the original chart?
Welp, it records the original domain (source). So that it can restore to that domain if the focus is cleared, and also to stop you from zooming or panning past the edge of the graph.
But notice from the source link above that we have an escape hatch. It records the original domain when the X scale is set. So, instead of setting elasticX, we can calculate the extent of the data, set the domain of the scale, and tell the chart that the scale is new:
chart.on('preRedraw', () => {
if(!zoom.filters().length) {
var xExtent = d3.extent(speedSumGroup.all(), kv => kv.key);
chart.x(chart.x().domain(xExtent));
}
});
New fiddle with zooming issues fixed.
There is still one glitch which Joerg points out: if you are moving the brush while data comes in, the brush handles occasionally will occasionally stray from the ends of the brush. In my experience, these kinds of glitches are pretty common in D3 (and dynamic charting in general), because it's difficult to think about data changing during user interaction. It probably could be fixed inside the library (perhaps an interrupted transition?) but I'm not going to get into that here.

Manually selecting a feature in a map generated by d3.js (for the purposes of zooming in this case)

I'd like to implement a map that zooms in on an area similar to Mike's click-zoom-example http://bl.ocks.org/mbostock/2206590
In fact I have this working fine already. My problem is that I can't rely on the click event to implement the zoom — the zoom will be triggered by another event (a link). So when I get to this part of Mike's code:
function clicked(d) {
var x, y, k;
if (d && centered !== d) {
var centroid = path.centroid(d);
...
I'm a bit of a loss as I don't have 'd'. So, I'm assuming that I can instead, manually pass 'd' to my click function when I call it. But how do I actually select the feature (which is what 'd' represents) I want from the map?
To be a bit more concrete, I have a map of the world. The paths within the SVG group contain class information (e.g. the one for France looks like):
<path class="subunit FXX FRA" id="FXX" data-subunit="FXX" data-countryName="France" data-countryCode="FRA" d="M153.88838704622088,519........"></path>
How would I pass the 'France object' to the clicked(d) function? Or is there another approach altogether that I should be trying.
Any tips or help greatly appreciated.
You can use D3's select for this purpose:
d3.select(".FRA").each(function(d) {
// same code as inside clicked
});
Get the data associated with the France object:
d3.select('.FXX.FRA').datum()
And pass it to clicked:
clicked(d3.select('.FXX.FRA').datum())

Openlayers WMS layer not zooming properly

I developed an script to display 2 layers ob base map.
but it is not zooming properlly.
I am using following code
can you please suggest me
<script type="text/javascript">
var map, layer, select, hover, control;
function init(){
map = new OpenLayers.Map('map', {
controls: [
new OpenLayers.Control.PanZoom(),
new OpenLayers.Control.Permalink(),
new OpenLayers.Control.Navigation()
]
});
layer = new OpenLayers.Layer.WMS(
"States WMS/WFS",
"http://localhost:8080/geoserver/topp/wms",
{layers: 'topp:india_road',transparent: true}
);
select = new OpenLayers.Layer.Vector("Selection", {styleMap:
new OpenLayers.Style(OpenLayers.Feature.Vector.style["select"])
});
hover = new OpenLayers.Layer.Vector("Hover");
map.addLayers([layer, hover, select]);
var wmsLayer = new OpenLayers.Layer.Google('Google Layer',{} );
map.addLayer(wmsLayer);
map.setCenter(new OpenLayers.Bounds(143.83482400000003,-43.648056,148.47914100000003,-39.573891).getCenterLonLat(), 5);
}
</script>
What do you mean by not zooming properly?
Are you finding that they are not matching up with the google maps when zooming in as far as you can go, or the zoom work until you try to pan and then the layers don't stay lined up with google maps? If this is the case then you are probably zooming in further than is supported by the Google map service. There is no easy way to fix this and I recommend you alter allowed scales and zoom with the following settings
minScale - float -- the minimum scale value at which the layer should display
maxScale - float -- the maximum scale value at which the layer should display
numZoomLevels - int -- Total number of zoom levels
Also please see this link on more information about google zoom scales.
If you are finding that none of your layers match the google background. I.E. they look like they are zoomed in out or offset then it is likely that your data is in a different projection to the google maps layer. Google maps uses EPSG:900913 as their SRS. The eisiest way to do this is to let geoserver do the re projection for you. Go to your layer setting in geoserver make sure you have the native projection set correctly and set the declared SRS to EPSG:900913.
If that doesn't help then I think we need more information on exactly what the problem is.

Resources