Pop Out SVG US State Feature Path On Click - reactjs

I'm rendering a geoAlbersUSA SVG map using d3-geo and topojson-client - as shown below - but I'm trying to do something fancy when a user clicks on the path. I would like to scale and translate the underlying path, in this case Idaho or Virginia, so that the state "floats" above the United States, but is still centered on the centroid. Every time I try to simply scale the <path/> element it ends up many pixels away; is there a way to calculate a translate() that corresponds to how much you have scaled the element?
const states = topojson.feature(us, us.objects.states as GeometryObject) as ExtendedFeatureCollection;
const path = geoPath()
.projection(geoAlbersUsa()
.fitSize([innerWidth as number, innerHeight as number], states)
);
<svg>
{
states.features.map(feature => {
return <path key={`state-path-${feature.id}`} d={path(feature) as string} onClick={() => updateScaleAndTranslate(feature)} />
}
}
</svg>

You need to set the transform-origin style property. It determines the point relative to which the transformations are applied. If you can find the centre of the path (for example, using getBBox()), and then set
.style("transform-origin", `${myCenter.x}px ${myCenter.y}px`)
This should change the way the transform is being applied.
I made a small example for you, using d3 v6. Note that as states are "expanded" they might now start to overlap, so functionally, I'd recommend to allow only one or a few states to be expanded like that.
const svg = d3.select('body')
.append("svg")
.attr("viewBox", [0, 0, 975, 610]);
d3.json("https://cdn.jsdelivr.net/npm/us-atlas#3/states-albers-10m.json").then((us) => {
const path = d3.geoPath()
const color = d3.scaleQuantize([1, 10], d3.schemeBlues[9]);
const statesContainer = svg.append("g");
const states = statesContainer
.selectAll("path")
.data(topojson.feature(us, us.objects.states).features)
.join("path")
.attr("class", "state")
.attr("fill", () => color(Math.random() * 10))
.attr("d", path)
.on("click", function(event, d) {
const {
x,
y,
width,
height
} = this.getBBox();
// First, move the state to the front so it's in front of others
const state = d3.select(this)
.attr("transform-origin", `${x + width / 2}px ${y + height / 2}px`).remove();
statesContainer.append(() => state.node());
d.properties.expanded = !d.properties.expanded;
state
.transition()
.duration(500)
.attr("transform", d.properties.expanded ? "scale(1.25)" : "scale(1)")
});
states
.append("title")
.text(d => d.properties.name)
});
.state {
stroke: #fff;
stroke-linejoin: round;
stroke-width: 2px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.js"></script>
<script src="https://d3js.org/topojson.v3.js"></script>

Related

How to keep my mapview.markers from rescaling when calling setGlobalState?

So i am using react native or more specifically expo mapview to render a mapview component with some custom markers. Theese markers are then connected to a scrollview in a way such that as when the scrollview is scrolled different markers corresponding to what is shown is then rescaled, aka gets 1.5x bigger this is also periodic so when im in between two they are both like 1.25x bigger code for this map with markers show below.
<MapView
ref={refMap}
style={styles.map}
region={state.region}
scrollEnabled={true}
zoomEnabled={true}
zoomTapEnabled={false}
rotateEnabled={true}
showsPointsOfInterest={false}
moveOnMarkerPress={false} // android only
minZoomLevel={13}
maxZoomLevel={16}
onMapReady={(resetMap)}
//onMarkerPress={onMarkerPress}
>
{/* creates markers on map with unique index; coordinates, img, title from mapData*/}
{state.markers.map((marker, index) => {
const scaleStyle = { // creates a scaleStyle by transforming/scaling the marker in func interpolations
transform: [
{
scale: interpolations[index].scale,
},
],
};
return (
<MapView.Marker
key={index}
coordinate={marker.coordinate}
onPress={onMarkerPress}
>
<Animated.View style={styles.markerWrap}>
<Animated.Image
source={marker.image}
style={[styles.marker, scaleStyle]} // adding the scaleStyle to the marker img (which is the marker)
//resizeMode= 'cover'
>
</Animated.Image>
</Animated.View>
</MapView.Marker>
);
})}
</MapView>
//function codes seen here
let markerAnimation = new Animated.Value(0);
//console.log(markerAnimation)
// Called when creating the markers (markers and cards has matching indexes)
// Setting inputRange (card index/position) for the animation, mapping it to an outputRange (how the marker should be scaled)
const interpolations = state.cards.map((card, index) => {
const inputRange = [
(index - 1) * Dimensions.get('window').width,
index * Dimensions.get('window').width,
((index + 1) * Dimensions.get('window').width),
];
const scale = markerAnimation.interpolate({
inputRange, //card index/positions [before, present, after]
outputRange: [1, 1.5, 1], //scales the marker [before, present, after]
extrapolate: "clamp"
});
return { scale };
});
// Called when a marker is pressed
const onMarkerPress = (mapEventData) => {
const markerIndex = mapEventData._targetInst.return.key; // returns the marker's index
let x = (markerIndex * Dimensions.get('window').width); // setting card x-position
refCards.current.scrollTo({x: x, y: 0, animated: true}); // scroll to x-position with animation
};
And whilst this whole interaction has been working exactly like i imagined i have this button that takes me to another page in the app which on arrival needs to fetch some data trough an api call. and this data depends on the index of the scrollview. In this other screen where the scrollview is also present i just use the onMomentumScrollEnd to set a global state value to the corresponding value(code for this global state shown below) this works but i would like to do exacty the same thing in the screen containing the map. but whenever a global state change is called the scaling "resets" so that the first one is the one "highlighted" even tho the scrollview is somewhere completly different.
//this is a index.js containing the store
import { createGlobalState } from 'react-hooks-global-state';
const region = {
latitude: 59.85843,
longitude: 17.63356,
latitudeDelta: 0.01,
longitudeDelta: 0.0095,
}
const { setGlobalState, useGlobalState } = createGlobalState({
currentIndex: 0,
initialMapState: {
markers: [],
region: region,
cards: [],
}
}
)
//and in a screen this can be used to change/read
import {setGlobalState, useGlobalState} from '../state' //imports it
const [currentIndex] = useGlobalState("currentIndex") //reads value
//and this is how i use the onMomentumScrollend
const onMomentumScrollEnd = ({ nativeEvent }) => {
const index = Math.round(nativeEvent.contentOffset.x / Dimensions.get('window').width);
if (index !== currentIndex) {
setGlobalState('currentIndex',index)
}
};
Any help is greatly appreciated as well im simply lost, i have tried storing the index in some sort of ref which worked fine and then try to update it on some sort of callback on the useFocusEffect but this got buggy real fast and ended up not really working. I have also tried to google to find some sort of way to keep the markers from not rerendering on state change, something which seems impossible? altough i think it would solve my problem as the markers dont depend on the state at all.
*edit
I also feel like i forgot to add that i am extremly happy to switch to any other sort of global state manager if it contains some sort of way to keep this fromh happening
**edit
So after revising this problem i found a soloution which i am not happy with, but is working.
useEffect(() => {
//scrolls back without animation when currentIndex is changed to handle react autorerender
//And scrolls if value is changed in eventscreen
refCards.current.scrollTo({
x: currentIndex * Dimensions.get("window").width,
y: 0,
animated: false,
});
}, [currentIndex]);

FabricJS object is not selectable/clickable/resizable in top left corner (React + FabricJs)

I have FabricJs canvas. And I have multiple objects outside canvas. When I click on object, it appears in Canvas. I am not doing anything else with this object programmatically.
Here is part of code that add image to Canvas (it is React code):
const { state, setState } = React.useContext(Context);
const { canvas } = state;
...
const handleElementAdd = (event, image) => {
const imageSize = event.target.childNodes[0].getBoundingClientRect();
const imageUrl = `/storage/${image.src}`;
new fabric.Image.fromURL(imageUrl, img => {
var scale = 1;
if (img.width > 100) {
scale = 100/img.width;
}
var oImg = img.set({
left: state.width / 2 - img.width * scale,
top: state.height / 2 - img.height * scale,
angle: 0})
.scale(scale);
canvas.add(oImg).renderAll.bind(canvas);
canvas.setActiveObject(oImg);
});
};
...
<div
key={img.name}
onClick={(e) => handleElementAdd(e, img)}
className={buttonClass}
>
It appears, I can drag and drop, resize, select it. However, when my object is placed in top left corner of canvas, it is not clickable, resizable etc. I can select larger area and then object is selected, but it doesn't respond to any events like resize, select etc.
Not sure where to look at.
I read that if coordinates are changed programmatically object.setCoord() to object can help, but in this case I do not understand where to put it.
You have to put (call) object.setCoords() each time you have modified the object, or moved the object. So, you can call it on mouseUp event of the canvas.
You can write a function naming "moveObject" in which you get the object you are trying to move/moved/updated. And then just selectedObject.setCoords().
Then call that function inside canvas.on('selection:updated', e => {----})

Error "path.merge is not a function" when creating an axis for bar chart

Just as some information, I installed d3, d3-axis, and d3-scale individually as so
npm i --save d3
npm i --save d3-axis
npm i --save d3-scale
and import then into my file as so
import * as d3 from "d3";
import * as scale from "d3-scale";
import * as axis from "d3-axis";
When trying to create the xAxis, I keep getting the error as mentioned in the title above. I have been banging my head against the wall trying to figure this out and watching Youtube videos and following their work word for work and line by line. Still, no luck. Can anyone help me? I assume it's because of the d3-axis or d3-scale stuff, but I can't figure it out.
Current Dummy Code
type Props = {||};
type DummyData = {|
id: string,
value: number,
region: string,
|};
type State = {|
dummyData: Array<DummyData>,
|};
const WIDTH = 800;
const HEIGHT = 500;
const MARGIN = {top: 80, right: 180, bottom: 80, left: 180};
const PADDING = 0.1;
class D3BarChart extends React.Component<Props, State> {
state: State = {
dummyData: [
{id: "d1", value: 10, region: "USA"},
{id: "d2", value: 11, region: "India"},
{id: "d3", value: 12, region: "China"},
{id: "d4", value: 6, region: "Germany"},
],
};
componentDidMount() {
const {dummyData} = this.state;
const xScale = scale
.scaleBand()
.domain(d3.range(dummyData.length))
.range([MARGIN.left, WIDTH - MARGIN.right])
.padding(PADDING);
const yScale = scale
.scaleLinear()
.domain([0, d3.max(dummyData, d => d.value)])
.nice()
.range([HEIGHT - MARGIN.bottom, MARGIN.top]);
const xAxis = g =>
g.attr("transform", `translate(0,${HEIGHT - MARGIN.bottom})`).call(
axis
.axisBottom(xScale)
.tickFormat(i => data[i].name)
.tickSizeOuter(0),
);
const yAxis = g => g =>
g
.attr("transform", `translate(${MARGIN.left},0)`)
.call(axis.axisLeft(yScale).ticks(null, data.format))
.call(g => g.select(".domain").remove())
.call(g =>
g
.append("text")
.attr("x", -MARGIN.left)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(data.y),
);
const svg = d3
.select("#test")
.insert("svg")
.attr("viewBox", [0, 0, WIDTH, HEIGHT]);
svg.append("g")
.attr("fill", "steelblue")
.selectAll("rect")
.data(dummyData)
.attr("x", (d, i) => xScale(i))
.attr("y", d => yScale(d.value))
.attr("height", d => yScale(0) - yScale(d.value))
.attr("width", xScale.bandwidth());
svg.append("g").call(xAxis);
svg.append("g").call(yAxis);
svg.node();
}
render(): React.Node {
return (
<View>
<LabelLarge>{i18n.doNotTranslate("D3.js")}</LabelLarge>
<Strut size={Spacing.xLarge_32} />
<div id="test"></div>
</View>
);
}
}
Without being able to reproduce the issue with the Typescript boilerplate that specific error is a little tough to diagnose.
There is a working, slightly refactored version of your code with a class-based React component here.
But based on the comment "does anything look off?", there are a couple of things that jumped out in the snippet.
You shouldn't need to import scale and axis individually. Use import * from 'd3' as d3, and call the scale and axis methods from the d3 object.
Directly appending to document seems like it might be an anti-pattern and may cause problems. Suggest avoiding that if you can.
The y-axis won't render as written yAxis = (g) => (g) =>. Change that to
const yAxis = (g) =>
g
.attr("transform", `translate(${MARGIN.left},0)`)
.call(d3.axisLeft(yScale).ticks(null, dummyData.format))
.call((g) => g.select(".domain").remove())
.call(
(g) =>
g
.append("text")
.attr("x", -MARGIN.left)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
// .text(dummyData.y)
);
and your y-axis will render.
Call the scale and axis functions directly from d3, like this:
const xScale = d3.scaleBand()
.domain(d3.range(dummyData.length))
.range([MARGIN.left, WIDTH - MARGIN.right])
.padding(PADDING);
const yScale = d3.scaleLinear()
.domain([0, d3.max(dummyData, d => d.value)])
.nice()
.range([HEIGHT - MARGIN.bottom, MARGIN.top]);
Appending a g to your svg will not render any bars. (See screenshot).
You'll need to create the DOM element to allow D3 to bind your data
to, like this:
svg
.selectAll(".bar")
.data(dummyData)
.enter()
.append("rect")
.attr("class", "bar")
.attr("fill", "steelblue")
.attr("x", (d, i) => xScale(i))
.attr("y", (d) => yScale(d.value))
.attr("width", xScale.bandwidth())
.attr("height", (d) => yScale(0) - yScale(d.value));
That will render your bars:
Lastly, even if you do choose to stay with a class-based component approach (as opposed to React Hooks), do consider using a ref, since since D3 may accidentally interact with unrelated components or DOM elements, such as when other components have elements with the same class being selected.
Using a ref should guarantee the correct selection, and is generally the preferred pattern.
It's a hooks based approach, but I think it's worth checking out Amelia Wattenberger's blog post on this for more detail.
Again, working code is here, feel free to fork and use if it's useful for you.
Hope this is helpful! ✌️
Not the best solution, but using the script tag in your body instead of using npm i d3 d3-axis d3-scale fixed the problem very quick.
To do this with React, you can do it in componentDidMount or useEffect.
componentDidMount() {
const script = document.createElement("script");
script.src = "https://d3js.org/d3.v7.min.js";
script.async = true;
document.body && document.body.appendChild(script);
}

How to redraw text rendered using SVGRenderer in Highcharts React?

I am using the SVGRenderer to draw the total value in the center of donut chart shown below:
The code to do this is shown below (please see CodeSandbox here)
export const PieChart = ({ title, totalLabel, pieSize, pieInnerSize, data }: PieChartProps) => {
const chartRef = useRef(null);
const [chartOptions, setChartOptions] = useState(initialOptions);
useEffect(() => {
// set options from props
setChartOptions({...});
// compute total
const total = data.reduce((accumulator, currentValue) => accumulator + currentValue.y, 0);
// render total
const totalElement = chart.renderer.text(total, 0, 0).add();
const totalElementBox = totalElement.getBBox();
// Place total
totalElement.translate(
chart.plotLeft + (chart.plotWidth - totalElementBox.width) / 2,
chart.plotTop + chart.plotHeight / 2
);
...
}, [title, totalLabel, pieSize, pieInnerSize, data]);
return (
<HighchartsReact
highcharts={Highcharts}
containerProps={{ style: { width: '100%', height: '100%' } }}
options={chartOptions}
ref={chartRef}
/>
);
};
However this approach has two issues:
When chart is resized, the total stays where it is - so it is no longer centered inside the pie.
When the chart data is changed (using the form), the new total is drawn over the existing one.
How do I solve these issues? With React, I expect the chart to be fully re-rendered when it is resized or when the props are changed. However, with Highcharts React, the chart seems to keep internal state which is not overwritten with new props.
I can suggest two options for this case:
Use the events.render callback, destroy and render a new label after each redraw:
Demo: https://jsfiddle.net/BlackLabel/ps97bxkg/
Use the events.render callback to trasnlate those labels after each redraw:
Demo: https://jsfiddle.net/BlackLabel/6kwag80z/
Render callback triggers after each chart redraw, so it is fully useful in this case - more information
API: https://api.highcharts.com/highcharts/chart.events.render
I'm not sure if this helps, but I placed items inside of highcharts using their svgrenderer functionality (labels, in particular, but you can also use the text version of svgrenderer), and was able to make them responsive
I was able to do something by manually changing the x value of my svgRenderer label.
I'm in angular and have a listener for screen resizing:
Also Note that you can change the entire SVGRenderer label with the attr.text property.
this.chart = Highcharts.chart(.....);
// I used a helper method to create the label
this.chart.myLabel = this.labelCreationHelperMethod(this.chart, data);
this.windowEventService.resize$.subscribe(dimensions => {
if(dimensions.x < 500) { //this would be your charts resize breakpoint
// here I was using a specific chart series property to tell where to put my x coordinate,
// you can traverse through the chart object to find a similar number,
// or just use a hardcoded number
this.chart.myLabel.attr({ y: 15, x: this.chart.series[0].points[0].plotX + 20 });
} else {
this.chart.myLabel.attr({ y: 100, x: this.chart.series[0].points[0].plotX + 50 });
}
}
//Returns the label object that we keep a reference to in the chart object.
labelCreationHelperMethod() {
const y = screen.width > 500 ? 100 : 15; 
const x = screen.width > 500 ? this.chart.series[0].points[0].plotX + 50 :
this.chart.series[0].points[0].plotX + 20
// your label
const label = `<div style="color: blue"...> My Label Stuff</div>`
return chart.renderer.label(label, x, y, 'callout', offset + chart.plotLeft, chart.plotTop + 80, true)
.attr({
fill: '#e8e8e8',
padding: 15,
r: 5,
zIndex: 6
})
.add();
}

How to develop responsive layer container in Leaflet?

I'm using leaflet.js to develop a drawing layer tool with the full-page map. Users will draw a group of layers to describe an industrial product's parts.
The leaflet map and its layers should be fit to screen. For example, if a user draws a circle to the left bottom corner with 1920x1080 resolution pc, I should see it in the left bottom corner with my 1366x768 resolution pc. The leaflet map is resizing with the page correctly but the circle is being out of the screen in my pc because of its latitude-longitude in this example.
I tried to develop this example with invalidateSize, maxBounds, fitBounds, etc. but haven't succeeded yet. How can I develop this scenario?
Saved GeoJSON from 1920x1080 with layers looking:
Loaded same GeoJSON from 1366x768 looking (attention to left corner):
Map (React):
const corner1 = L.latLng(-180, -180);
const corner2 = L.latLng(180, 180);
const bounds = L.latLngBounds(corner1, corner2);
<Map
ref={map => {
this.tryToCreate(map);
}}
className="c-leaflet-map-map"
center={[0, 0]}
zoom={0}
maxZoom={10}
zoomControl={false}
onzoomend={this.zoomLevelsChange}
maxBounds={bounds}
maxBoundsViscosity={1.0}
zoomSnap={0}
crs={L.CRS.Simple}
>
<ImageOverlay
bounds={map.backgroundImageBounds || map.bounds}
attribution="&copy TofnaTech"
url={
map.backgroundImage ||
map.backgroundImageURL ||
TransparentImage
}
opacity={map.opacity}
className={cn({ transparentImage: !map.backgroundImage })}
/>
<FeatureGroup
ref={controller => {
this.controller = controller;
this.tryToCreate();
}}
>
<EditControl
position="topleft"
onCreated={this._onCreated}
onEditStart={this.onEditStart}
onEditStop={this.onEditStop}
onEdited={this.onEdited}
draw={{
polyline: false,
polygon: {
shapeOptions: { ...defaultShapeOptions }
},
rectangle: {
shapeOptions: { ...defaultShapeOptions }
},
circle: {
shapeOptions: { ...defaultShapeOptions }
},
marker: false,
circlemarker: false
}}
edit={{
remove: false
}}
/></Map>
Using React-Leaflet is a little different. You can accomplish what you're trying to do in componentDidMount, where you can make a reference to your map and then call fitBounds on that.
Edit: Adding the rectangle
However, the next issue is resolution. Leaflet will maintain the aspect ratio so you actually need to render your circle (or rectangle)in the corner dynamically if you always want it to show up in the bottom left corner of the screen regardless of the aspect ratio of the screen and browser. So in componentDidMount, just after you set the new bounds, you can get those bounds, and define a rectangle (or anything) to always render in the same spot relative to say, the getBounds()._southWest bound. In this code I wrapped this code in a setTimeout because the animation takes a moment and that moment needs to pass to get the new bounds values from getBounds(). I'm sure there's a way to reduce or remove that animation time.
To keep the rectangle in the same spot, I wrote a onMove function for the map to always recalculate the position regardless of moving the map around or resizing the window.
Note that the size of the rectangle is a little slapdash in this example, but I think you'll have an easier time getting a consistent size using CRS, as CRS is a lot more uniform than lats and lngs.
class Map extends React.Component {
state = {
rectCoords: null
};
componentDidMount() {
const map = this.mapReference.leafletElement;
window.map = map;
console.log("Initiates with bounds:", map.getBounds());
map.fitBounds([[32.852169, -90.5322], [33.852169, -85.5322]]);
setTimeout(() => {
console.log("Bounds immediately reset to", map.getBounds());
const rectCoords = [
[
map.getBounds()._southWest.lat + 0.1,
map.getBounds()._southWest.lng + 0.1
],
[
map.getBounds()._southWest.lat + 1,
map.getBounds()._southWest.lng + 1
]
];
this.setState({ rectCoords });
}, 500);
}
onMapmove = () => {
const map = this.mapReference.leafletElement;
const rectCoords = [
[
map.getBounds()._southWest.lat + 0.1,
map.getBounds()._southWest.lng + 0.1
],
[map.getBounds()._southWest.lat + 1, map.getBounds()._southWest.lng + 1]
];
this.setState({ rectCoords });
}
render() {
return (
<LeafletMap
ref={mapReference => this.mapReference = mapReference}
zoom={4}
center={[33.852169, -100.5322]}
onMove={() => this.onMapmove() } >
<TileLayer ... />
{this.state.rectCoords && (
<Rectangle
bounds={this.state.rectCoords}
color={"#ff7800"}
weight={1}
/>
)}
</LeafletMap>
);
}
}
So even though the map initiates with certain bounds, it immediately fits it to your desired bounds, and renders the rectangle always in the bottom left corner in the same spot.
Here is a working codesandbox. I'm sure this can be adapted to using CRS Simple as well.

Resources