Center and fit svg elements inside parent viewbox - javascript

I have two nested svg elements, one outer <svg> with a with a dynamic view box (changed by panning and zooming) and an inner element <g> where I can put any number of svg elements. On one of my pages I want to show some personal information, and an svg family tree. I'm trying to automatically center and fit this family tree inside the svg window. First I thought I would just iterate through all my elements and get their position to find the min/max coordinates, but a much more efficient way would be to calculate the trees bounding box, the bounding box of the <g> element, in the outer svg's view box coordinates.
Now I've tried a few solutions. Just getting a bounding box using getBBox() on the inner element returns a bounding box with seemingly correct width and height, but weird x and y. I don't seem to fully understand what getBBox does. I also tried using inner.getBoundingClientRect() and transforming it to containers coordinates, but end up with the exact same bouding box as returned by getBBox()! I hope you can help me. Here's my vue component.
<template>
<div id="svg-container" class="svg-container">
<svg id="svg-canvas-outer" viewBox="0 0 1000 1000">
<svg id="svg-canvas-inner" :viewBox="viewBox">
<g id="svg-content">
<slot></slot> <!-- <--family tree -->
</g>
</svg>
</svg>
</div>
</template>
<script>
export default {
name: 'SvgContainer',
props: {
width: {
type: Number,
default: 1000,
},
height: {
type: Number,
default: 1000,
},
margin: {
type: Number,
default: 50,
},
},
data() {
return {
showControls: false,
controlsWidthBase: 60,
controlsHeightBase: 120,
viewBoxWidth: this.width,
viewBoxHeight: this.height,
viewBoxScale: 1.0,
viewBoxX: 0,
viewBoxY: 0,
startPoint: null,
endPanX: 0,
endPanY: 0,
currentlyPanning: false,
innerSvgElement: undefined,
mousePos: '',
mouseViewportPos: '',
bbox: '',
};
},
computed: {
viewBox() {
return `${this.viewBoxX} ${this.viewBoxY} ${this.scaledViewBoxWidth} ${this.scaledViewBoxHeight}`;
},
viewBoxPretty() {
return `${this.viewBoxX.toFixed(2)} ${this.viewBoxY.toFixed(2)} ${this.scaledViewBoxWidth.toFixed(2)} ${this.scaledViewBoxHeight.toFixed(2)}`;
},
scaledViewBoxWidth() {
return this.viewBoxWidth * this.viewBoxScale;
},
scaledViewBoxHeight() {
return this.viewBoxHeight * this.viewBoxScale;
},
},
methods: {
zoomIn() {
const scaleFactor = this.viewBoxScale * 0.8;
this.$_SvgContainer_zoom(scaleFactor);
},
zoomOut() {
const scaleFactor = this.viewBoxScale / 0.8;
this.$_SvgContainer_zoom(scaleFactor);
},
$_SvgContainer_zoom(newScale) {
const oldWidth = this.scaledViewBoxWidth;
const oldHeight = this.scaledViewBoxHeight;
this.viewBoxScale = newScale;
const newWidth = this.scaledViewBoxWidth;
const newHeight = this.scaledViewBoxHeight;
this.panX((newWidth - oldWidth) / 2);
this.panY((newHeight - oldHeight) / 2);
},
sceneBoundingBox() {
const outer = document.getElementById('svg-canvas-outer');
const inner = document.getElementById('svg-content');
const {x: bx, y: by, bottom: bb, right: br} = inner.getBoundingClientRect();
const pXY = outer.createSVGPoint();
const pBR = outer.createSVGPoint();
pXY.x = bx;
pXY.y = by;
pBR.x = br;
pBR.y = bb;
const inverseCTM = outer.getScreenCTM().inverse();
const {x, y} = pXY.matrixTransform(inverseCTM);
const {x: r, y: b} = pBR.matrixTransform(inverseCTM);
const bbox = inner.getBBox(); // <-- same result as below :(
return {x: x, y: y, width: (r - x), height: (b - y)};
},
centerAndFitContent() {
const {x: boundingX, y: boundingY, width, height} = this.sceneBoundingBox();
// Pan svg container to fit
const boundingCenterX = boundingX + (width / 2);
const boundingCenterY = boundingY + (height / 2);
const sceneCenterX = this.scaledViewBoxWidth / 2;
const sceneCenterY = this.scaledViewBoxHeight / 2;
this.panX(sceneCenterX - boundingCenterX);
this.panY(sceneCenterY - boundingCenterY);
// Zoom svg container to fit
const widthWithMargin = width + this.margin * 2;
const heightWithMargin = height + this.margin * 2;
while (widthWithMargin > this.scaledViewBoxWidth || heightWithMargin > this.scaledViewBoxHeight) {
this.zoomOut();
}
},
},
mounted() {
this.centerAndFitContent();
},
};
</script>
<style scoped>
</style>

Related

Exclude edges from participating in the layout

Consider a graph like the one shown below:
I would like to be able to display/hide the red edges (forget that they are hand drawn) shown below when the user clicks a button or similar:
I don't want the red edges to participate in the layout but instead for them to be shown as a kind of overlay. It would be nice if the edges could try to avoid overlapping any nodes in their path, but its definitely not required.
I think if I could set a boolean flag on the edges telling the layout engine to either include or exclude them from the layout setup, it could work. There is a physics parameter on the edge that I can override, but it doesn't seem to help - the edge still participates in the layout.
I could probably also write some scripting which tracks the nodes and draw the red edges in another graph above, but that is specifically what I want to avoid.
When using a hierarchical layout in vis network (options.layout.hierarchical.enabled = true) there doesn't appear to be an option which achieves this. This could however be achieved with an overlay. The question mentions that this isn't desired, but adding it as an option. An example is incorporated into the post below and also at https://jsfiddle.net/7abovhtu/.
In summary the solution places an overlay canvas on top of the vis network canvas. Clicks on the overlay canvas are passed through to the vis network canvas due to the CSS pointer-events: none;. Extra edges are drawn onto the overlay canvas using the positioning of the nodes. Updates to the overlay canvas are triggered by the vis network event afterDrawing which triggers whenever the network changes (dragging, zooming, etc.).
This answer makes use of the closest point to an ellipse calculation provided in the answer https://stackoverflow.com/a/18363333/1620449 to end the lines at the edge of the nodes. This answer also makes use of the function in the answer https://stackoverflow.com/a/6333775/1620449 to draw an arrow on a canvas.
// create an array with nodes
var nodes = new vis.DataSet([
{ id: 1, label: "Node 1" },
{ id: 2, label: "Node 2" },
{ id: 3, label: "Node 3" },
{ id: 4, label: "Node 4" },
{ id: 5, label: "Node 5" },
{ id: 6, label: "Node 6" },
{ id: 7, label: "Node 7" },
]);
// create an array with edges
var edges = new vis.DataSet([
{ from: 1, to: 2 },
{ from: 2, to: 3 },
{ from: 3, to: 4 },
{ from: 3, to: 5 },
{ from: 3, to: 6 },
{ from: 6, to: 7 }
]);
// create an array with extra edges displayed on button press
var extraEdges = [
{ from: 7, to: 5 },
{ from: 6, to: 1 }
];
// create a network
var container = document.getElementById("network");
var data = {
nodes: nodes,
edges: edges,
};
var options = {
layout: {
hierarchical: {
enabled: true,
direction: 'LR',
sortMethod: 'directed',
shakeTowards: 'roots'
}
}
};
var network = new vis.Network(container, data, options);
// Create an overlay for displaying extra edges
var overlayCanvas = document.getElementById("overlay");
var overlayContext = overlayCanvas.getContext("2d");
// Function called to draw the extra edges, called on initial display and
// when the network completes each draw (due to drag, zoom etc.)
function drawExtraEdges(){
// Resize overlay canvas in case the continer has changed
overlayCanvas.height = container.clientHeight;
overlayCanvas.width = container.clientWidth;
// Begin drawing path on overlay canvas
overlayContext.beginPath();
// Clear any existing lines from overlay canvas
overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
// Loop through extra edges to draw them
extraEdges.forEach(edge => {
// Gather the necessary coordinates for the start and end shapres
const startPos = network.canvasToDOM(network.getPosition(edge.from));
const endPos = network.canvasToDOM(network.getPosition(edge.to));
const endBox = network.getBoundingBox(edge.to);
// Determine the radius of the ellipse based on the scale of network
// Start and end ellipse are presumed to be the same size
const scale = network.getScale();
const radiusX = ((endBox.right * scale) - (endBox.left * scale)) / 2;
const radiusY = ((endBox.bottom * scale) - (endBox.top * scale)) / 2;
// Get the closest point on the end ellipse to the start point
const endClosest = getEllipsePt(endPos.x, endPos.y, radiusX, radiusY, startPos.x, startPos.y);
// Now we have an end point get the point on the ellipse for the start
const startClosest = getEllipsePt(startPos.x, startPos.y, radiusX, radiusY, endClosest.x, endClosest.y);
// Draw arrow on diagram
drawArrow(overlayContext, startClosest.x, startClosest.y, endClosest.x, endClosest.y);
});
// Apply red color to overlay canvas context
overlayContext.strokeStyle = '#ff0000';
// Make the line dashed
overlayContext.setLineDash([10, 3]);
// Apply lines to overlay canvas
overlayContext.stroke();
}
// Adjust the positioning of the lines each time the network is redrawn
network.on("afterDrawing", function (event) {
// Only draw the lines if they have been toggled on with the button
if(extraEdgesShown){
drawExtraEdges();
}
});
// Add button event to show / hide extra edges
var extraEdgesShown = false;
document.getElementById('extraEdges').onclick = function() {
if(!extraEdgesShown){
if(extraEdges.length > 0){
// Call function to draw extra lines
drawExtraEdges();
extraEdgesShown = true;
}
} else {
// Remove extra edges
// Clear the overlay canvas
overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
extraEdgesShown = false;
}
}
//////////////////////////////////////////////////////////////////////
// Elllipse closest point calculation
// https://stackoverflow.com/a/18363333/1620449
//////////////////////////////////////////////////////////////////////
var halfPI = Math.PI / 2;
var steps = 8; // larger == greater accuracy
// calc a point on the ellipse that is "near-ish" the target point
// uses "brute force"
function getEllipsePt(cx, cy, radiusX, radiusY, targetPtX, targetPtY) {
// calculate which ellipse quadrant the targetPt is in
var q;
if (targetPtX > cx) {
q = (targetPtY > cy) ? 0 : 3;
} else {
q = (targetPtY > cy) ? 1 : 2;
}
// calc beginning and ending radian angles to check
var r1 = q * halfPI;
var r2 = (q + 1) * halfPI;
var dr = halfPI / steps;
var minLengthSquared = 200000000;
var minX, minY;
// walk the ellipse quadrant and find a near-point
for (var r = r1; r < r2; r += dr) {
// get a point on the ellipse at radian angle == r
var ellipseX = cx + radiusX * Math.cos(r);
var ellipseY = cy + radiusY * Math.sin(r);
// calc distance from ellipsePt to targetPt
var dx = targetPtX - ellipseX;
var dy = targetPtY - ellipseY;
var lengthSquared = dx * dx + dy * dy;
// if new length is shortest, save this ellipse point
if (lengthSquared < minLengthSquared) {
minX = ellipseX;
minY = ellipseY;
minLengthSquared = lengthSquared;
}
}
return ({
x: minX,
y: minY
});
}
//////////////////////////////////////////////////////////////////////
// Draw Arrow on Canvas Function
// https://stackoverflow.com/a/6333775/1620449
//////////////////////////////////////////////////////////////////////
function drawArrow(ctx, fromX, fromY, toX, toY) {
var headLength = 10; // length of head in pixels
var dX = toX - fromX;
var dY = toY - fromY;
var angle = Math.atan2(dY, dX);
ctx.fillStyle = "red";
ctx.moveTo(fromX, fromY);
ctx.lineTo(toX, toY);
ctx.lineTo(toX - headLength * Math.cos(angle - Math.PI / 6), toY - headLength * Math.sin(angle - Math.PI / 6));
ctx.moveTo(toX, toY);
ctx.lineTo(toX - headLength * Math.cos(angle + Math.PI / 6), toY - headLength * Math.sin(angle + Math.PI / 6));
}
#container {
width: 100%;
height: 80vh;
border: 1px solid lightgray;
position: relative;
}
#network, #overlay {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
#overlay {
z-index: 100;
pointer-events: none;
}
<script src="https://visjs.github.io/vis-network/standalone/umd/vis-network.min.js"></script>
<button id="extraEdges">Toggle Extra Edges</button>
<div id="container">
<div id="network"></div>
<canvas width="600" height="400" id="overlay"></canvas>
</div>
This can be achieved using either the physics or hidden options on the extra edges (those in red). For reference, these options are described in more detail at https://visjs.github.io/vis-network/docs/network/edges.html.
Please note the below options do not work when hierarchical layout is used as set in the Vis Network options options.layout.hierarchical.enabled = true.
Physics - An example of using the physics option is https://jsfiddle.net/6oac73p0. However as you mentioned this may cause overlaps with nodes which have physics enabled. The extra edges are set to dashed in this example to ensure everything is still visible.
Hidden - An example of using the hidden option is https://jsfiddle.net/xfcuvtgk/ and also incorporated into this post below. Edges set to hidden are still part of the physics calculation when the layout is generated, which you mentioned wasn't desired, however this does mean they fit nicely when later displayed.
// create an array with nodes
var nodes = new vis.DataSet([
{ id: 1, label: "Node 1" },
{ id: 2, label: "Node 2" },
{ id: 3, label: "Node 3" },
{ id: 4, label: "Node 4" },
{ id: 5, label: "Node 5" },
]);
// create an array with edges
var edges = new vis.DataSet([
{ from: 1, to: 3 },
{ from: 1, to: 2 },
{ from: 2, to: 4 },
{ from: 2, to: 5 },
{ from: 3, to: 3 },
{ from: 4, to: 5, color: 'red', hidden: true, arrows: 'to', extra: true },
{ from: 3, to: 5, color: 'red', hidden: true, arrows: 'to', extra: true },
{ from: 1, to: 5, color: 'red', hidden: true, arrows: 'to', extra: true }
]);
// create a network
var container = document.getElementById("mynetwork");
var data = {
nodes: nodes,
edges: edges,
};
var options = {};
var network = new vis.Network(container, data, options);
document.getElementById('extraEdges').onclick = function() {
// Extract the list of extra edges
edges.forEach(function(edge){
if(edge.extra){
// Toggle the hidden value
edge.hidden = !edge.hidden;
// Update edge back onto data set
edges.update(edge);
}
});
}
#mynetwork {
width: 600px;
/* Height adjusted for Stack Overflow inline demo */
height: 160px;
border: 1px solid lightgray;
}
<script src="https://visjs.github.io/vis-network/standalone/umd/vis-network.min.js"></script>
<button id="extraEdges">Show/Hide Extra Edges</button>
<div id="mynetwork"></div>

React-native-game-engine + matter.js dynamic scale of bodies

I'm trying to build a screen with circular bodies (bubbles). The bubbles should be able to change the scale with some interval, e.g. every 3 seconds and random value (from 1 to 3). The scale should change the size of a bubble and its mass.
Calling
Matter.Body.scale
doesn't make any effect. Bubbles keep staying the same. Maybe because I'm not using sprites with textures, but the Game-Engine entities with renderer as React.PureComponent instead. Not sure
Bubble node:
export interface BubbleProps {
body: Matter.Body;
item: Item;
}
class BubbleNode extends React.Component<BubbleProps> {
render() {
const {body, item} = this.props;
const x = body.position.x - body.circleRadius;
const y = body.position.y - body.circleRadius;
const style: ViewStyle = {
position: 'absolute',
left: x,
top: y,
width: body.circleRadius * 2,
height: body.circleRadius * 2,
backgroundColor: item.color,
borderRadius: body.circleRadius,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
padding: 3,
};
return <View style={style}>{item.content}</View>;
}
}
Bubble Entity:
const body = Matter.Bodies.circle(
randomX,
randomY,
RADIUS, //default radius 25
{
mass: 30,
}
);
return {
body,
item,
renderer: BubbleNode,
};
Update method:
// find out if the entity was already added to the world
if (Object.prototype.hasOwnProperty.call(entities, id)) {
//update scale
const scale = bubble.item.scale
Matter.Body.scale(bubble.body, scale, scale)
} else {
//add new entity
entities[id] = bubble;
Matter.World.add(physics.world, [bubble.body]);
}
Also, I need to make this scaling smooth
Edit:
Interesting thing. I was able to scale the bubbles using Matter.Body.scale but right before adding to the world. I wonder if there is a method to update the bodies after adding to the world.
Final edit
this is the method I'm using to set up the world and specify entities for the GameEngine framework
private setupWorld = (layout: LayoutRectangle) => {
const engine = Matter.Engine.create({enableSleeping: false});
const world = engine.world;
world.gravity.x = 0;
world.gravity.y = 0;
//center gravity body
Matter.use(MatterAttractors);
var attractiveBody = Matter.Bodies.circle(
layout.width / 2,
layout.height / 2,
1,
{
isSensor: true,
plugin: {
attractors: [centerGravity],
},
},
);
Matter.World.add(world, attractiveBody);
//walls
const wallThickness = 5;
let floor = Matter.Bodies.rectangle(
layout.width / 2,
layout.height - wallThickness / 2,
layout.width,
wallThickness,
{isStatic: true},
);
let ceiling = Matter.Bodies.rectangle(
layout.width / 2,
wallThickness / 2,
layout.width,
wallThickness,
{isStatic: true},
);
let left = Matter.Bodies.rectangle(
wallThickness / 2,
layout.height / 2,
wallThickness,
layout.height,
{isStatic: true},
);
let right = Matter.Bodies.rectangle(
layout.width - wallThickness / 2,
layout.height / 2,
wallThickness,
layout.height,
{isStatic: true},
);
Matter.World.add(world, [floor, ceiling, left, right]);
//basic entitites
this.entities = {
physics: {engine, world},
floor: {body: floor, color: 'green', renderer: Wall},
ceiling: {body: ceiling, color: 'green', renderer: Wall},
left: {body: left, color: 'green', renderer: Wall},
right: {body: right, color: 'green', renderer: Wall},
};
};
And here is the method that will be triggered by a parent Component with some interval
public updateNodes = (items: Item[]) => {
if (!this.state.mounted || !this.entities || !this.entities.physics || !this.layout) {
console.log('Missiing required data');
return;
}
const layout = this.layout
const entities = this.entities
const bubbles: BubbleEntity[] = items.map((item) => {
const randomX = randomPositionValue(layout.width);
const randomY = randomPositionValue(layout.height);
const body = Matter.Bodies.circle(
randomX,
randomY,
RADIUS, {
mass: 30,
}
);
body.label = item.id
return {
body,
item,
renderer: BubbleNode,
};
});
const physics = this.entities.physics as PhysicsEntity;
const allBodies = Matter.Composite.allBodies(physics.world)
bubbles.forEach((bubble) => {
//update existing or add new
const id = `bubble#${bubble.item.id}`;
if (Object.prototype.hasOwnProperty.call(entities, id)) {
//do physical node update here
//update scale and mass
const scale = bubble.item.scale
console.log('Updating item', id, scale);
//right her there used to be an issue because I used **bubble.body** which was not a correct reference to the world's body.
//so when I started to use allBodies array to find a proper reference of the body, everything started to work
let body = allBodies.find(item => item.label === bubble.item.id)
if (!!body) {
const scaledRadius = RADIUS*scale
const current = body.circleRadius || RADIUS
const scaleValue = scaledRadius/current
Matter.Body.scale(body, scaleValue, scaleValue)
}else{
console.warn('Physycal body not found, while the entity does exist');
}
} else {
console.log('Adding entity to the world');
entities[id] = bubble;
Matter.World.add(physics.world, [bubble.body]);
}
});
this.entities = entities
};
In the future, I'm going to improve that code, I will use some variables for the body and will create a matter.js plugin that will allow me to scale the body smoothly and not instant as it works right now. Also, the method above requires some clean, short implementation instead of that garbage I made attempting to make it work
Your example isn't exactly complete; it's not clear how (or if) the MJS engine is running. The first step is to make sure you have an actual rendering loop using calls to Matter.Engine.update(engine); in a requestAnimationFrame loop.
React doesn't seem critical here. It shouldn't affect the result since x/y coordinates and radii for each body in the MJS engine are extracted and handed to the View component, so the data flows in one direction. I'll leave it out for the rest of this example but it should be easy to reintroduce once you have the MJS side working to your satisfaction.
The way to scale a body in MJS is to call Matter.body.scale(body, scaleX, scaleY). This function recomputes other physical properties such as mass for the body.
There's an annoying caveat with this function: instead of setting an absolute scale as a JS canvas context or CSS transformation might, it sets a relative scale. This means each call to this function changes the baseline scaling for future calls. The problem with this is that rounding errors can accumulate and drift can occur. It also makes applying custom tweens difficult.
Workarounds are likely to be dependent on what the actual animation you hope to achieve is (and may not even be necessary), so I'll avoid prescribing anything too specific other than suggesting writing logic relative to the radius as the point of reference to ensure it stays within bounds. Other workarounds can include re-creating and scaling circles per frame.
Another gotcha when working with circles is realizing that MJS circles are n-sided polygons, so small circles lose resolution when scaled up. Again, this is use-case dependent, but you may wish to create a Bodies.polygon with more sides than would be created by Bodies.circle if you experience unusual behavior.
That said, here's a minimal, complete example of naive scaling that shows you how you can run an animation loop and call scale to adjust it dynamically over time. Consider it a proof of concept and will require adaptation to work for your use case (whatever that may be).
const engine = Matter.Engine.create();
const circles = [...Array(15)].map((_, i) => {
const elem = document.createElement("div");
elem.classList.add("circle");
document.body.append(elem);
const body = Matter.Bodies.circle(
// x, y, radius
10 * i + 60, 0, Math.random() * 5 + 20
);
return {
elem,
body,
offset: i,
scaleAmt: 0.05,
speed: 0.25,
};
});
const mouseConstraint = Matter.MouseConstraint.create(
engine, {element: document.body}
);
const walls = [
Matter.Bodies.rectangle(
// x, y, width, height
innerWidth / 2, 0, innerWidth, 40, {isStatic: true}
),
Matter.Bodies.rectangle(
innerWidth / 2, 180, innerWidth, 40, {isStatic: true}
),
Matter.Bodies.rectangle(
0, innerHeight / 2, 40, innerHeight, {isStatic: true}
),
Matter.Bodies.rectangle(
300, innerHeight / 2, 40, innerHeight, {isStatic: true}
),
];
Matter.Composite.add(
engine.world,
[mouseConstraint, ...walls, ...circles.map(e => e.body)]
);
/*
naive method to mitigate scaling drift over time.
a better approach would be to scale proportional to radius.
*/
const baseScale = 1.00063;
(function update() {
requestAnimationFrame(update);
circles.forEach(e => {
const {body, elem} = e;
const {x, y} = body.position;
const {circleRadius: radius} = body;
const scale = baseScale + e.scaleAmt * Math.sin(e.offset);
Matter.Body.scale(body, scale, scale);
e.offset += e.speed;
elem.style.top = `${y - radius / 2}px`;
elem.style.left = `${x - radius / 2}px`;
elem.style.width = `${2 * radius}px`;
elem.style.height = `${2 * radius}px`;
elem.style.transform = `rotate(${body.angle}rad)`;
});
Matter.Engine.update(engine);
})();
* {
margin: 0;
padding: 0;
}
body, html {
height: 100%;
}
.circle {
border-radius: 50%;
position: absolute;
cursor: move;
background: rgb(23, 0, 36, 1);
background: linear-gradient(
90deg,
rgba(23, 0, 36, 1) 0%,
rgba(0, 212, 255, 1) 100%
);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.18.0/matter.min.js"></script>

Konva - Change offset position after zoom and rotation

I'm trying to implement a rotation and zoom feature with a slider. I need an image to always rotate from the center of the viewport. The main idea is changing the position and offset of the stage after drag event. Here's what I've tried
const width = window.innerWidth
const height = window.innerHeight
// scale is temp
let stage = new Konva.Stage({
container: 'canvas',
width,
height,
draggable: true,
offset: {
x: width / 2,
y: height / 2
},
x: width / 2,
y: height / 2
})
let mapLayer = new Konva.Layer()
let imageObj = new Image()
imageObj.onload = function () {
let map = new Konva.Image({
image: imageObj,
prevX: 0,
prevY: 0
})
layer.add(map)
stage.add(mapLayer)
stage.on('dragstart', () => {
map.prevX = map.getAbsolutePosition().x
map.prevY = map.getAbsolutePosition().y
})
stage.on('dragend', () => {
let curX = map.getAbsolutePosition().x
let curY = map.getAbsolutePosition().y
let deltaX = Math.abs(map.prevX - curX)
let deltaY = Math.abs(map.prevY - curY)
if (curX > map.prevX) {
stage.offsetX(stage.offsetX() - deltaX)
stage.x(stage.x() - deltaX)
} else {
stage.offsetX(stage.offsetX() + deltaX)
stage.x(stage.x() + deltaX)
}
if (curY > map.prevY) {
stage.offsetY(stage.offsetY() - deltaY)
stage.y(stage.y() - deltaY)
} else {
stage.offsetY(stage.offsetY() + deltaY)
stage.y(stage.y() + deltaY)
}
stage.draw()
})
}
(if you want a full source code, clone it from here and run yarn run dev from terminal, the app lives on localhost:3000
It works fine when the image is in the normal position (not zoomed and rotated yet) but after ANY kind of rotation or zooming, dragging the stage will cause the stage to be re-positioned in a weird fashion (the offset is still correct though). How can I set position and offset correctly?
The solution to your initial issue below.
Note that I put a circle and a tooltip as well to a single layer to be able to drag them as a single one. Also, it should be added after map to have binding work.
import Konva from 'konva'
import map from './../resources/unpar.svg'
const width = window.innerWidth
const height = window.innerHeight
// scale is temp
let stage = new Konva.Stage({
container: 'canvas',
width,
height,
offset: {
x: width / 2,
y: height / 2
},
x: width / 2,
y: height / 2
})
let layer = new Konva.Layer({draggable: true})
let testCircle = new Konva.Circle({
x: 633,
y: 590,
radius: 10,
fill: 'white',
stroke: 'black'
})
testCircle.on('mousemove', function () {
tooltip.position({
x: testCircle.x() - 90,
y: testCircle.y() - 50
})
tooltip.text('Pintu Depan Gedung 10')
tooltip.show()
layer.batchDraw()
})
testCircle.on('mouseout', function () {
tooltip.hide()
layer.draw()
})
var tooltip = new Konva.Text({
text: '',
fontFamily: 'Calibri',
fontSize: 18,
padding: 5,
textFill: 'white',
fill: 'black',
alpha: 0.75,
visible: false
})
let imageObj = new Image()
imageObj.onload = function () {
const map = new Konva.Image({
image: imageObj,
})
layer.add(map)
layer.add(testCircle)
layer.add(tooltip)
stage.add(layer)
}
imageObj.src = map
export default stage

zoom by mouse wheel on canvas Angular+D3

I want to make zoom on HTML canvas by mouse wheel like on google map.
I have two canvases on my project, one for showing an image and one for edit the image. I do for both of the canvases transformation and scale according to the mouse position.
Everything goes fine. the zoom and the draw after the zoom. the problem is when I try to save the original position of the drawing after the zoom - the position is not correct.
HTML:
<canvas id="backgroundCanvas" #backgroundCanvas class="background-canvas" width="930" height="710"></canvas>
<canvas #editCanvas mouseWheel class="edit-canvas" width="930" height="710" [style.cursor]="editMode ? 'pointer' : '-webkit-grab'" (mouseWheelUp)="mouseWheelUpFunc($event)" (mouseWheelDown)="mouseWheel($event)"></canvas>
JS:
this.canvas1 = d3.select("#editCanvas");
this.context = this.canvas1.node().getContext("2d");
this.width = this.canvas1.property("width");
this.height = this.canvas1.property("height");
this.canvas1.call(d3.zoom()
.scaleExtent([1, 4])
.on("zoom", () => {
if (this.zoomStep === 0) {
this.isZoomed = false; //set isZoomed to false for disable to drag the image
}
var wheel = d3.event.sourceEvent.wheelDelta/120;//n or -n
var zoom = 0;
if(wheel < 0)//if it's zoom out
{
this.zoomStep--;
this.setCurrentScale(this.zoomFactor, false);
this.resetBackgroundTopLeft();
console.log("wheel is under 0 ");
zoom = 1/1.05;
}
else//if it's zoom in
{
this.zoomStep++;
this.isZoomed = true;//set isZoomed to true for enable to drag the image
this.setCurrentScale(this.zoomFactor, true);
zoom = 1.05;
}
this.currentzoom *= zoom; //set the current zoom for know whem to stop
this.clearBackgroundCanvas(); // clear the background image befor draw it again.
/*********************************change the background canvas (where the image is)****************************************************/
this.backgroundContext.save();
this.backgroundContext.translate(
d3.event.transform.x,
d3.event.transform.y
);
this.backgroundContext.scale(d3.event.transform.k, d3.event.transform.k);
this.backgroundContext.drawImage(this.curentImage, this.imageTopLeft.x, this.imageTopLeft.y ,this.img.w * this.currentScale, this.img.h * this.currentScale);
this.backgroundContext.restore();
/***********************************change the edit canvas (where the tags are)**************************************************/
this.editContext.save();
this.editContext.translate(
d3.event.transform.x,
d3.event.transform.y
);
this.editContext.scale(d3.event.transform.k, d3.event.transform.k);
this.draw() //clear and then draw the marker again
this.editContext.restore();
/*************************************************************************************/
}))
.on("mousedown.zoom", () =>{
this.onMouseDown1(d3.event);
})
/**This function set the sacle to zoom In or Out according the mouse wheel*/
setCurrentScale = (scale: number, zoomIn: boolean) => {
this.currentScale *= zoomIn ? scale : 1 / scale;
}
/**This function clear all the canvase area */
clearBackgroundCanvas() {
this.backgroundContext.clearRect(0, 0, this.canvas.w, this.canvas.h);
}
onMouseDown1(evt: MouseEvent) {
var coordinates = d3.mouse(this.canvas1.node());
console.log("onMouseDown" + evt.offsetX , evt.offsetY ,coordinates)
if (this.editMode) {
if (this.isInDrawingArea(evt.offsetX, evt.offsetY, this.imageTopLeft)) {
// return relative mouse position
this.currentDrawnMarkerStart = {
x: coordinates[0],
y: coordinates[1]
};
this.isDrawingRect = true;
if(this.scale == null)
this.scale ={k :1 };
this.drawRectBorder(
this.currentDrawnMarkerStart.x ,
this.currentDrawnMarkerStart.y ,
250*this.currentScale*this.scale.k,
250*this.currentScale*this.scale.k,
'#004de6',2*this.scale.k);//color for the rect tag on drawing
const m = {
x: this.currentDrawnMarkerStart.x,
y: this.currentDrawnMarkerStart.y,
w: 250*this.currentScale,
h: 250*this.currentScale,
isSelected: false,
isHovered: false
};
this.addMarker(m);
}
} else if (!this.isDragging && this.isZoomed) {
this.dragStart = {
x: evt.offsetX,
y: evt.offsetY
};
this.isDragging = true;
}
}
drawRectBorder = (x0: number, y0: number, w: number, h: number, color: string, borderW: number = 1) => {
this.editContext.strokeStyle = color;
this.editContext.lineWidth = borderW;
this.editContext.beginPath();
this.editContext.rect(x0, y0, w, h);
this.editContext.stroke();
this.editContext.closePath();
}
addMarker = (r: IMarker) => {
console.log(this.currentScale)
const original = editorUtil.transformMarkerToOriginal(editorUtil.getMarkerTopLeft(r),
this.imageTopLeft, this.currentScale);
// Save coordinates on canvas
this.currentMarkers.push(original);
console.log(original)
}
/** Get the marker's top left corner, with absolute values for w,h */
export const getMarkerTopLeft = (m: IMarker): IMarker => {
const res: IMarker = {
x: m.x + (m.w < 0 ? m.w : 0),
y: m.y + (m.h < 0 ? m.h : 0),
w: Math.abs(m.w),
h: Math.abs(m.h),
isSelected: m.isSelected,
isHovered: m.isHovered
};
return res;
};
/** Get the marker's coordinates with regards to the original image dimensions */
export const transformMarkerToOriginal = (m: IMarker, imageTopLeft: Coordinate, scale: number): IMarker => {
const res: IMarker = {
x: Math.floor((m.x - imageTopLeft.x) / scale),
y: Math.floor((m.y - imageTopLeft.y) / scale),
w: Math.floor(Math.abs(m.w / scale)),
h: Math.floor(Math.abs(m.h / scale)),
isSelected: false,
isHovered: false
};
return res;
};
Thank's!!

FabricJS Clipping and SVG Export

I have a small fabricjs scene which loads images and clips them to SVG shapes that are defined in an SVG layout. The clipping is done using the code that is specified in this stack.
I have two issues:
When I export to SVG (see the "export to SVG" button), the clipping does not persist. Is this possible, perhaps to export to SVG maybe by converting the clippings to in SVG?
I have a SVG string inside the let clippingSVG variable. Is it possible to apply this SVG as another clipPath for the complete canvas (including export possibility), or the group of images?
Thanks in advance
This is rough implementation of top of the other answer that propose a toSVG method change to make clip ( fixed ) respected in toSVG.
The non fixed case is harder, i hope this helps.
var img01URL = 'http://fabricjs.com/assets/printio.png';
var img02URL = 'http://fabricjs.com/lib/pug.jpg';
var img03URL = 'http://fabricjs.com/assets/ladybug.png';
var img03URL = 'http://fabricjs.com/assets/ladybug.png';
function toSvg() {
document.getElementById('svg').innerHTML = canvas.toSVG();
}
fabric.Image.prototype.toSVG = function(reviver) {
var markup = this._createBaseSVGMarkup(), x = -this.width / 2, y = -this.height / 2, clipUrl = '';
if (this.clipPath) {
var id = fabric.Object.__uid++;
if (this.clipPath.fixed) {
markup.push('<clipPath id="myClip' + id + '">\n',
this.clipPath.toSVG(reviver),
'</clipPath>\n');
}
clipUrl = ' clip-path="url(#myClip' + id + ')" ';
}
markup.push('<g ', clipUrl, '>',
'<g transform="', this.getSvgTransform(), this.getSvgTransformMatrix(), '"', '>\n',
'\t<image ', this.getSvgId(), 'xlink:href="', this.getSvgSrc(true),
'" x="', x, '" y="', y,
'" style="', this.getSvgStyles(),
// we're essentially moving origin of transformation from top/left corner to the center of the shape
// by wrapping it in container <g> element with actual transformation, then offsetting object to the top/left
// so that object's center aligns with container's left/top
'" width="', this.width,
'" height="', this.height,
'"></image>\n'
);
if (this.stroke || this.strokeDashArray) {
var origFill = this.fill;
this.fill = null;
markup.push(
'<rect ',
'x="', x, '" y="', y,
'" width="', this.width, '" height="', this.height,
'" style="', this.getSvgStyles(),
'"/>\n'
);
this.fill = origFill;
}
markup.push('</g></g>\n');
return reviver ? reviver(markup.join('')) : markup.join('');
};
fabric.Image.prototype._render = function(ctx) {
// custom clip code
if (this.clipPath) {
ctx.save();
if (this.clipPath.fixed) {
var retina = this.canvas.getRetinaScaling();
ctx.setTransform(retina, 0, 0, retina, 0, 0);
// to handle zoom
ctx.transform.apply(ctx, this.canvas.viewportTransform);
//
this.clipPath.transform(ctx);
}
this.clipPath._render(ctx);
ctx.restore();
ctx.clip();
}
// end custom clip code
var x = -this.width / 2, y = -this.height / 2, elementToDraw;
if (this.isMoving === false && this.resizeFilter && this._needsResize()) {
this._lastScaleX = this.scaleX;
this._lastScaleY = this.scaleY;
this.applyResizeFilters();
}
elementToDraw = this._element;
elementToDraw && ctx.drawImage(elementToDraw,
0, 0, this.width, this.height,
x, y, this.width, this.height);
this._stroke(ctx);
this._renderStroke(ctx);
};
var canvas = new fabric.Canvas('c');
canvas.setZoom(0.5)
fabric.Image.fromURL(img01URL, function(oImg) {
oImg.scale(.25);
oImg.left = 10;
oImg.top = 10;
oImg.clipPath = new fabric.Circle({radius: 40, top: 50, left: 50, fixed: true, fill: '', stroke: '' });
canvas.add(oImg);
canvas.renderAll();
});
fabric.Image.fromURL(img02URL, function(oImg) {
oImg.scale(.40);
oImg.left = 180;
oImg.top = 0;
oImg.clipPath = new fabric.Path('M85.6,606.2c-13.2,54.5-3.9,95.7,23.3,130.7c27.2,35-3.1,55.2-25.7,66.1C60.7,814,52.2,821,50.6,836.5c-1.6,15.6,19.5,76.3,29.6,86.4c10.1,10.1,32.7,31.9,47.5,54.5c14.8,22.6,34.2,7.8,34.2,7.8c14,10.9,28,0,28,0c24.9,11.7,39.7-4.7,39.7-4.7c12.4-14.8-14-30.3-14-30.3c-16.3-28.8-28.8-5.4-33.5-11.7s-8.6-7-33.5-35.8c-24.9-28.8,39.7-19.5,62.2-24.9c22.6-5.4,65.4-34.2,65.4-34.2c0,34.2,11.7,28.8,28.8,46.7c17.1,17.9,24.9,29.6,47.5,38.9c22.6,9.3,33.5,7.8,53.7,21c20.2,13.2,62.2,10.9,62.2,10.9c18.7,6.2,36.6,0,36.6,0c45.1,0,26.5-15.6,10.1-36.6c-16.3-21-49-3.1-63.8-13.2c-14.8-10.1-51.4-25.7-70-36.6c-18.7-10.9,0-30.3,0-48.2c0-17.9,14-31.9,14-31.9h72.4c0,0,56-3.9,70.8,26.5c14.8,30.3,37.3,36.6,38.1,52.9c0.8,16.3-13.2,17.9-13.2,17.9c-31.1-8.6-31.9,41.2-31.9,41.2c38.1,50.6,112-21,112-21c85.6-7.8,79.4-133.8,79.4-133.8c17.1-12.4,44.4-45.1,62.2-74.7c17.9-29.6,68.5-52.1,113.6-30.3c45.1,21.8,52.9-14.8,52.9-14.8c15.6,2.3,20.2-17.9,20.2-17.9c20.2-22.6-15.6-28-16.3-84c-0.8-56-47.5-66.1-45.1-82.5c2.3-16.3,49.8-68.5,38.1-63.8c-10.2,4.1-53,25.3-63.7,30.7c-0.4-1.4-1.1-3.4-2.5-6.6c-6.2-14-74.7,30.3-74.7,30.3s-108.5,64.2-129.6,68.9c-21,4.7-18.7-9.3-44.3-7c-25.7,2.3-38.5,4.7-154.1-44.4c-115.6-49-326,29.8-326,29.8s-168.1-267.9-28-383.4C265.8,13,78.4-83.3,32.9,168.8C-12.6,420.9,98.9,551.7,85.6,606.2z',{top: 0, left: 180, fixed: true, fill: '', stroke: '', scaleX: 0.2, scaleY: 0.2 });
canvas.add(oImg);
canvas.renderAll();
});
#c {
border:1px solid #ccc;
}
<script src="//cdnjs.cloudflare.com/ajax/libs/fabric.js/1.7.17/fabric.min.js"></script>
<button onClick='toSvg();'>TOSVG</button>
<canvas id="c" width="400" height="400"></canvas>
<div id="svg"></div>
Thank you AndreaBogazzi for starting me off on the right foot. I'd prefer to use a subclass of fabric.Image rather than replace a couple of its prototype methods. Cut-and-paste can be dangerous; in fact, the newest version of fabricjs is already incompatible with the that solution. So, here's a subclass version of the same solution. In my case I'm always using a fixed clipPath so I removed all references to fixed.
const ClippedImage = fabric.util.createClass(fabric.Image, {
type: 'ClippedImage',
initialized: function(options) {
options || (options = {})
this.callSuper('initialize', options)
},
_render: function(ctx) {
if (this.clipPath) {
ctx.save()
var retina = this.canvas.getRetinaScaling()
ctx.setTransform(retina, 0, 0, retina, 0, 0)
ctx.transform.apply(ctx, this.canvas.viewportTransform)
this.clipPath.transform(ctx)
this.clipPath._render(ctx)
ctx.restore()
ctx.clip()
}
this.callSuper('_render', ctx)
},
toSVG: function(reviver) {
let result = this.callSuper('toSVG')
if(this.clipPath) {
const clipId = `Clipped${fabric.Object.__uid++}`
result = `
<clipPath id="${clipId}">
${this.clipPath.toSVG(reviver)}
</clipPath>
<g clip-path="url(#${clipId})">${result}</g>`
}
return reviver ? reviver(result) : result
}
})

Categories