convert length to point in svg (polygon) dynamically - javascript

I am doing a small assignment to find out the type of the triangle whether is equilateral, isosceles, or scalene based on the values a,b,c.
my problem is that I couldn't manage to convert the a,b,c values in order to draw the shape by using SVG (polygon) since it takes points and I have only values
function triangle(a, b, c) {
if (a === b && a === c && b === c) return "equilateral";
if (a === b && a === c) return "isosceles";
else {
return "scalene";
}
}
<div>
<svg className="traingle" height="400" width="400">
<polygon points={`200 ${b}, 200 ${a}, 0 ${c}`} />
</svg>
</div>

This question probably belongs in Maths StackExchange, but anyway.
Firstly, you need to first check whether it is possible for the combination of side lengths to form a triangle.
The way to do that is with the Triangle Inequality Theorem. It states that each of the side lengths must be less than the sum of the other two.
So in your example, the lengths [5,200,300] cannot form a triangle because 300 is not less than (5 + 200).
Also, none of the lengths should be <= 0.
So let's work with three valid side lengths: [150, 200, 300].
The way to get the three points is to start with one side, and draw that first. So we will start with the side that is 150.
You can draw that side anywhere, but let's make the line go from (0,0) to (150,0).
To get the third point we need to find where the other two sides would meet. Imagine two circles centred at each end of that first line. Each circle has a radius corresponding to the remaining two side lengths. The way to calculate the third point is to find the instersection point(s) of those two circles.
svg {
background-color: linen;
width: 500px;
}
line {
stroke: black;
stroke-width: 2;
}
.c300 {
fill: none;
stroke: blue;
stroke-width: 2;
}
.c200 {
fill: none;
stroke: green;
stroke-width: 2;
}
line.la {
stroke: blue;
stroke-dasharray: 4 6;
}
.lb {
stroke: green;
stroke-dasharray: 4 6;
}
<svg viewBox="-350 -350 750 700">
<line class="l150" x1="0" y1="0" x2="150" y2="0"/>
<circle class="c300" cx="0" cy="0" r="300"/>
<circle class="c200" cx="150" cy="0" r="200"/>
<line class="la" x1="0" y1="0" x2="241.667" y2="-177.756"/>
<line class="lb" x1="150" y1="0" x2="241.667" y2="-177.756"/>
</svg>
If we use Wolfram Alpha's version of the formula then we get the following:
d^2 - r^2 + R^2
x = -----------------
2 * d;
and
sqrt( 4 * d^2 * R^2 - ( d^2 - r^2 + R^2 )^2 )
y = -----------------------------------------------
2 * d
where d is the length of that first side, and r and R are the lengths of the other two sides.
So for our example:
R = 300
r = 200
d^2 - r^2 + R^2 = 150^2 - 300^2 + 200^2
= 72500
x = 72500 / 300
= 241.667
y = sqrt( 4 * 22500 * 90000 - 72500^2) / 300
= sqrt( 2843750000 ) / 300
= 53326.823 / 300
= 177.756
And you can see these values for x and y in the example SVG above.
Note that the circles intersect both above and below the first line. So 177.756 and -177.656 are both valid solutions for y.

Let's assume point A is always at 0,0, and point B has the same y coordinate. You can calculate position of C using the law of cosines:
const calculateTriangle = (a, b, c) => {
const ax = 0;
const ay = 0;
const bx = ax + c;
const by = ay;
const cosAlpha = (a * a - b * b - c * c) / (b * c * 2);
const alpha = Math.acos(cosAlpha);
const cx = ax - b * cosAlpha;
const cy = ay + b * Math.sin(alpha);
return {ax, ay, bx, by, cx, cy};
};

Here is a working snippet with calculateTriangle. Please note that the side length numbers should be valid
const calculateTriangle = (a, b, c) => {
const ax = 0;
const ay = 0;
const bx = ax + c;
const by = ay;
const cosAlpha = (a * a - b * b - c * c) / (b * c * 2);
const alpha = Math.acos(cosAlpha);
const cx = ax - b * cosAlpha;
const cy = ay + b * Math.sin(alpha);
return {ax, ay, bx, by, cx, cy};
};
const onDraw = () => {
const a = d3.select('#side-a').node().value;
const b = d3.select('#side-b').node().value;
const c = d3.select('#side-c').node().value;
const {ax, ay, bx, by, cx, cy} = calculateTriangle(parseInt(a), parseInt(b), parseInt(c));
d3.select('polygon').attr('points', `${ax},${ay} ${bx},${by} ${cx},${cy}`)
console.log('T: ', t);
}
d3.select('button').on('click', onDraw);
#wrapper {
display: flex;
flex-direction: row;
}
#container {
font-family: Ubuntu;
font-size: 16px;
display: flex:
flex-direction: column;
}
#container > div {
display: flex;
flex-direction: row;
margin: 10px 0 0 20px;
}
#container > div > span {
width: 10px;
}
#container > div > input {
margin-left: 20px;
width: 50px;
}
#container button {
margin-left: 30px;
width: 60px;
}
svg {
border: 1px solid grey;
margin: 10px 0 0 20px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id='wrapper'>
<div id='container'>
<div>
<span>A: </span><input type="number" id="side-a" value="50"/>
</div>
<div>
<span>B: </span><input type="number" id="side-b" value="40"/>
</div>
<div>
<span>C: </span><input type="number" id="side-c" value="30"/>
</div>
<div>
<button>DRAW</button>
</div>
</div>
<svg width="110" height="110">
<polygon transform="translate(10,10)" />
</svg>
</div>

Related

How to center svg text according to div height & width?

I have a svg path with a textPath connecting 2 divs from center like this:
item1.style.top="20px";
item1.style.left="20px";
item2.style.top="40px";
item2.style.left="160px";
var x1=parseFloat(item1.style.left)+ item1.offsetWidth / 2;
var y1=parseFloat(item1.style.top) + item1.offsetHeight / 2;
var x2=parseFloat(item2.style.left) + item2.offsetWidth / 2;
var y2=parseFloat(item2.style.top) + item2.offsetHeight / 2;
path1.setAttribute("d",`M ${x1} ${y1} L ${x2} ${y2}`)
*{
margin:0;
}
div{
height:2rem;
width:2rem;
position:absolute;
background-color:black;
box-sizing:border-box;
}
<div id="item1"></div>
<div id="item2" style="width:10rem; height:3rem"></div>
<svg id="svg1" style="overflow:visible">
<path id="path1" fill="none" stroke="red" stroke-width="3" />
<text font-size="24" dy="-10" text-anchor="middle">
<textPath href="#path1" fill="green" startOffset="50%">T</textPath>
</text>
</svg>
But as you can see the Text "T" isn't technically centered because of the height & width
so is there a way to shift the text (without changing the path d) into visual center?
like this:
Note
The height, width & position of the divs will change so a more flexible & versatile approach would be better
You could find the intersection points between the rectangles' borders and your textPath.
The final path start and end coordinates would be the calculated intersection points.
item1.style.top = "20px";
item1.style.left = "20px";
item2.style.top = "40px";
item2.style.left = "160px";
let svg = document.querySelector('svg');
let cx1 = parseFloat(item1.style.left) + item1.offsetWidth / 2;
let cy1 = parseFloat(item1.style.top) + item1.offsetHeight / 2;
let cx2 = parseFloat(item2.style.left) + item2.offsetWidth / 2;
let cy2 = parseFloat(item2.style.top) + item2.offsetHeight / 2;
// text path coordinates
let lTextP = [cx1, cy1, cx2, cy2];
renderLine(svg, lTextP)
// rect1: left, right, top, bottom
let x1 = parseFloat(item1.style.left);
let rx1 = x1 + item1.offsetWidth;
let y1 = parseFloat(item1.style.top);
let by1 = y1 + item1.offsetHeight;
// rect2: left, right, top, bottom
let x2 = parseFloat(item2.style.left);
let rx2 = x1 + item2.offsetWidth;
let y2 = parseFloat(item2.style.top);
let by2 = y2 + item2.offsetHeight;
// 1st rect: right border
let l1 = [rx1, y1, rx1, by1];
renderLine(svg, l1)
// 2nd rect: left border
let l2 = [x2, y2, x2, by2];
renderLine(svg, l2)
// find intersections between textpath and rect borders
let intersection1 = getVectorIntersection(l1, lTextP, true);
renderPoint(svg, intersection1, 'orange', '1%');
let intersection2 = getVectorIntersection(l2, lTextP, true);
renderPoint(svg, intersection2, 'orange', '1%');
// shorten text path according to intersections
[cx1, cy1] = intersection1;
[cx2, cy2] = intersection2;
path1.setAttribute("d", `M ${cx1} ${cy1} L ${cx2} ${cy2}`);
/**
* helper: get intersection coordinates
* based on
* source: https://dirask.com/posts/JavaScript-calculate-intersection-point-of-two-lines-for-given-4-points-VjvnAj
*/
function getVectorIntersection(l1, l2, exact = false, decimals = 3) {
let intersection = [];
let dx1 = l1[0] - l1[2];
let dy1 = l1[1] - l1[3];
let dx2 = l2[0] - l2[2];
let dy2 = l2[1] - l2[3];
// down part of intersection point formula
let d = dx1 * dy2 - dy1 * dx2;
if (d === 0) {
console.log('parallel')
return false;
} else {
// upper part of intersection point formula
let u1 = l1[0] * l1[3] - l1[1] * l1[2];
let u4 = l2[0] * l2[3] - l2[1] * l2[2];
// only exact intersections
let isIntersecting = u4 > d;
if (exact && !isIntersecting) {
return false;
}
// intersection point formula
let px = +((u1 * dx2 - dx1 * u4) / d).toFixed(decimals);
let py = +((u1 * dy2 - dy1 * u4) / d).toFixed(decimals);
intersection = [px, py];
}
return intersection;
}
// debug helper: render coordinates as markers
function renderPoint(svg, coords, fill = "red", r = "0.5%") {
if (coords.length) {
let marker =
'<circle cx="' +
coords[0] +
'" cy="' +
coords[1] +
'" r="' +
r +
'" fill="' +
fill +
'" ><title>' +
coords.join(", ") +
"</title></circle>";
svg.insertAdjacentHTML("beforeend", marker);
}
}
// debug helper: render lines
function renderLine(svg, coords, color = "purple", strokeWidth = 1) {
let [x1n, y1n, x2n, y2n] = coords;
let newLine =
'<line x1="' +
x1n +
'" y1="' +
y1n +
'" x2="' +
x2n +
'" y2="' +
y2n +
'" stroke-width="' + strokeWidth + '" stroke="' + color + '" />';
svg.insertAdjacentHTML("beforeend", newLine);
}
*{
margin:0;
}
.item{
height:2rem;
width:2rem;
position:absolute;
z-index:-1;
background-color:black;
box-sizing:border-box;
}
<div class="item" id="item1"></div>
<div class="item" id="item2" style="width:10rem; height:3rem"></div>
<svg id="svg1" style="overflow:visible">
<text font-size="24" dy="-10" text-anchor="middle">
<textPath href="#path1" fill="green" startOffset="50%">T</textPath>
</text>
<path id="path1" fill="none" stroke="red" stroke-width="3" stroke-linecap="square" />
</svg>
An easier alternative might be to draw the text path between the vertical centers like so:
var x1=parseFloat(item1.style.left)+ item1.offsetWidth ;
var y1=parseFloat(item1.style.top) + item1.offsetHeight / 2;
var x2=parseFloat(item2.style.left);
var y2=parseFloat(item2.style.top) + item2.offsetHeight / 2;
item1.style.top="20px";
item1.style.left="20px";
item2.style.top="40px";
item2.style.left="160px";
var x1=parseFloat(item1.style.left)+ item1.offsetWidth ;
var y1=parseFloat(item1.style.top) + item1.offsetHeight / 2;
var x2=parseFloat(item2.style.left);
var y2=parseFloat(item2.style.top) + item2.offsetHeight / 2;
path1.setAttribute("d",`M ${x1} ${y1} L ${x2} ${y2}`)
*{
margin:0;
}
.item{
height:2rem;
width:2rem;
position:absolute;
z-index:-1;
background-color:black;
box-sizing:border-box;
}
<div class="item" id="item1"></div>
<div class="item" id="item2" style="width:10rem; height:3rem"></div>
<svg id="svg1" style="overflow:visible">
<text font-size="24" dy="-10" text-anchor="middle">
<textPath href="#path1" fill="green" startOffset="50%">T</textPath>
</text>
<path id="path1" fill="none" stroke="red" stroke-width="3" stroke-linecap="square" />
</svg>
The solution provided by #herrstrietzel works but I found another solution
I used this solution and calculated the distance required to center and then used dx attribute to simply shift the text by that much

Is there a way calculate the ending position of an element before css transform matrix is applied?

I need to figure the ending bounding rect of an element before the css transform: matrix() transformation is applied. I have no idea really where to start and can't find any good articles addressing this.
So given you have the original position of the element with getBoundingClientRect() is there a reliable way to find the ending position if you have the matrix that is supposed to be applied.
I have built a scroll controller and I am trying to map the elements progress through the screen but I need the starting and ending position of the element. So I would need the position of the element after the css transformation is applied to figure out when the element has left the screen. Right now i am just applying the matrix and then running getBoundingClientRect() again. But this seems a bit hacky.
So given you have the original and ending matrix. And then you run the getBoundingClientRect() on the element to find its position. Is there a mathematical way to calculate the new bounding rect?
So for this example we will just use a 6 value matrix. But it would be nice to apply it to a full 16 value matrix as well:
const boundingRect = element.getBoundingClientRect();
const startingMatrix = [1, 0, 0, 1, 0, 0];
const endingMatrix = [2, 1, -1, 2, 200, 400];
// Now calculate the new bounding rect of element after ending matrix is applied.
I have tried the following based on this article https://dev.opera.com/articles/understanding-the-css-transforms-matrix/ recommended by the comments below. There is something that I obviously am missing or dont understand. Here is my attempt:
const applyToPoint = (matrix, point) => {
const multiplied = [
matrix[0] * point[0],
matrix[1] * point[1],
matrix[2] * point[0],
matrix[3] * point[1],
matrix[4] * point[0],
matrix[5] * point[1]
]
const result = [
multiplied[0] + multiplied[2] + multiplied[4],
multiplied[1] + multiplied[3] + multiplied[5]
]
return result
}
const box = document.querySelector('.box')
const startingMatrix = [1, 0, 0, 1, 0, 0]
box.style.transform = `matrix(1,0,0,1,0,0)`
const startingRect = box.getBoundingClientRect()
const endingMatrix = [2, 1, -1, 2, 200, 400]
box.style.transform = `matrix(2,1,-1,2,200,400)`
const endingRect = box.getBoundingClientRect()
const newPoint = applyToPoint(endingMatrix, [startingRect.x, startingRect.y])
console.log(newPoint, endingRect)
.box {
height: 50px;
width: 50px;
background: green;
margin: 50px 0;
}
<div class="box"></div>
I also tried :
const applyToPoint = (matrix, point) => [
matrix[0] * point[0] + matrix[2] * point[1] + matrix[4],
matrix[1] * point[0] + matrix[3] * point[1] + matrix[5]
]
Any help would be appreciated. Or pointers in the right direction.
The issue is that you're mixing two different coordinate systems:
The client system (used by getBoundingClientRect())
The local system (used by transform)
As stated in the linked article,
the local system's origin is the element's center:
When a transform is applied to an object, it creates a local coordinate system. By default, the origin —
the (0,0) point — of the local coordinate system lies at the object's center
Let's call [xc, yc] the element's center.
To transform a point (expressed in the client system), you need to do the following:
Convert the coordinates from client to local system (by subtracting xc and yc)
Apply the transform matrix
Convert the resulting coordinates back from local to client (by adding xc and yc)
Here is a code that transforms the 4 box corners and computes the associated bounding box:
var matrix = [2, 1, -1, 2, 200, 400];
var box = document.querySelector('.box');
var startingRect = box.getBoundingClientRect();
var xc = startingRect.left + startingRect.width/2;
var yc = startingRect.top + startingRect.height/2;
const applyMatrix = (matrix, point) => [
matrix[0] * point[0] + matrix[2] * point[1] + matrix[4],
matrix[1] * point[0] + matrix[3] * point[1] + matrix[5]
];
const clientToLocal = (point) => [point[0] - xc, point[1] - yc];
const localToClient = (point) => [point[0] + xc, point[1] + yc];
const transformPoint = (point) => localToClient(applyMatrix(matrix, clientToLocal(point)));
function getBoundingBox(pt1, pt2, pt3, pt4)
{
var x1 = Math.min(pt1[0], pt2[0], pt3[0], pt4[0]);
var y1 = Math.min(pt1[1], pt2[1], pt3[1], pt4[1]);
var x2 = Math.max(pt1[0], pt2[0], pt3[0], pt4[0]);
var y2 = Math.max(pt1[1], pt2[1], pt3[1], pt4[1]);
return {x: x1, y: y1, width: x2 - x1, height: y2 - y1};
}
var topLeft = [startingRect.left, startingRect.top];
var topRight = [startingRect.right, startingRect.top];
var bottomLeft = [startingRect.left, startingRect.bottom];
var bottomRight = [startingRect.right, startingRect.bottom];
var transformedBox = getBoundingBox(transformPoint(topLeft), transformPoint(topRight), transformPoint(bottomLeft), transformPoint(bottomRight));
console.log(transformedBox);
box.style.transform = 'matrix('+matrix.join()+')';
var endingRect = box.getBoundingClientRect();
console.log(endingRect);
Output:
{x: 158, y: 400, width: 150, height: 150}
DOMRect {x: 158, y: 400, width: 150, height: 150, top: 400, …}
As mentioned in the comments, you have to simply multiply the matrix (you can do just the 2 x 2 version, and then add the translation variables.) But as mentioned by Olivier, you need to first translate the coordinates, and then translate them back. This is easy enough by just calculating the center.
I would write this as a function from a rectangle and matrix (as a flat array of the six variables used for the CSS transforms) into another rectangle, either a simple {left, right, top, bottom} rectangle or if you want, a DomRect.
You can see it in this snippet (easier to see if you expand it with the "full page" link):
const transformRect = (rect, matrix) => {
const {left, top, right, bottom} = rect
const [a, b, c, d, tx, ty] = matrix
const dx = (left + right) / 2, dy = (top + bottom) / 2
const newCorners = [[left, top], [right, top], [right, bottom], [left, bottom]]
.map (([x, y]) => [
a * (x - dx) + c * (y - dy) + tx + dx,
b * (x - dx) + d * (y - dy) + ty + dy
])
const _left = Math .min (... newCorners .map (p => p [0]))
const _right = Math .max (... newCorners .map (p => p [0]))
const _top = Math .min (... newCorners .map (p => p [1]))
const _bottom = Math .max (... newCorners .map (p => p [1]))
return DOMRect.fromRect (
{x: _left, y: _top, width: _right - _left, height: _bottom - _top}
) // or just
// return {x: _left, y: _top, width: _right - _left, height: _bottom - _top}
}
const div = document .getElementById ('d2')
const rect = div.getBoundingClientRect()
console .log ('Before:' , rect)
const matrix = [2, 1, -1, 2, 200, 400]
div.style.transform = `matrix(${matrix .join (', ')})`
console .log ('After:', transformRect (rect, matrix))
.box {
height: 50px;
width: 50px;
background: green;
color: white;
}
#d1 {
background: #ccc;
position: absolute;
top: 8;
left: 8;
}
<div id="d1" class="box">shadow</div>
<div id="d2" class="box">content</div>
...to figure out when the element has left the screen.
If you want to do something when element enters or exits view then Intersection Observer is a better choice.
View following snippet in full page mode.
let observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting)
log.textContent = entry.target.textContent;
})
});
// set elements to observe
observer.observe(one);
observer.observe(two);
down.addEventListener('click', () => two.scrollIntoView({
behavior: "smooth",
block: "end",
inline: "nearest"
}));
body {
position: relative;
height: calc(150vh + 25px);
}
p {
position: sticky;
top: 1rem;
}
p #log {
background-color: yellow;
}
#down {
position: fixed;
top: 2rem;
right: 2rem;
background-color: skyblue;
padding: .3rem;
border-radius: 50%;
cursor: pointer;
user-select: none;
}
div {
height: 50px;
width: 50px;
background-color: wheat;
position: absolute;
left: 50vw;
}
#one {
background-color: rgb(250, 165, 165);
top: 50vh;
transform: translate(-50%, -50%);
}
#two {
background-color: lime;
top: 100%;
transform: translateX(-50%);
}
<span id="down">👇</span>
<p>Element in view: <span id="log"></span></p>
<div id="one">One</div>
<div id="two">Two</div>
The API is configurable you can even see how much of the element is visible.

How to plot degree on spiral chart using d3.js

I have a running Angular 9 application where SVG has a spiral chart with min and max value for the degree.
I am using d3.js to plot given value of degree on the spiral chart.
I have written the following code :
// min -> min degree, -140 in this example
// max -> max degree, 440 in this example
// currentDegree -> degree value to be ploted, 0 in this example
// svg -> svg containing spiral chart
// circle -> circle to be moved to depict current Degree position in the svg
void setDegree(min,max,currentDegree, svg, circle) {
const pathNode = svg.select('path').node();
const totalPathLength = pathNode.getTotalLength();
const yDomain = d3.scale.linear().domain([min, max]).range(
[0, totalPathLength]);
const currentPathLength = yDomain(currentDegree); // current path length
const pathPoint = pathNode.getPointAtLength(totalPathLength - currentPathLength);
circle.transition()
.duration(300)
.attrTween('cx', () => (t) => pathPoint.x)
.attrTween('cy', () => (t) => pathPoint.y);
}
Above code produces this output :
In the above image, 0 degrees is slightly shifted to the right but it should have been at the center as shown in the image below :
function setDegree(min, max, currentDegree, svg, circle) {
const pathNode = svg.select("path").node();
const totalPathLength = pathNode.getTotalLength();
const yDomain = d3
.scaleLinear()
.domain([min, max])
.range([0, totalPathLength]);
const currentPathLength = yDomain(currentDegree); // current path length
const pathPoint = pathNode.getPointAtLength(
totalPathLength - currentPathLength
);
circle
.transition()
.duration(300)
.attrTween("cx", () => t => pathPoint.x)
.attrTween("cy", () => t => pathPoint.y);
}
const svg = d3.select("svg");
const circle = d3.select("#cur_pos");
setDegree(-140, 410, 0, svg, circle);
p {
font-family: Lato;
}
.cls-3 {
fill: none;
stroke-width: 10px;
stroke: #000;
}
.cls-3,
.cls-4,
.cls-5 {
stroke-miterlimit: 10;
}
.cls-4,
.cls-5 {
stroke-width: 0.25px;
}
.cls-5 {
font-size: 60px;
font-family: ArialMT, Arial;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1031.1 1010.3" preserveAspectRatio="xMinYMin meet">
<title>Spiral Chart</title>
<g>
<g id="Spiral_Chart">
<g id="Spiral_Path">
<path
class="cls-3 svg-range-line"
d="M881.5,154.9C679.6-47,352.3-47,150.4,154.9c-195.9,195.9-195.9,513.4,0,709.3,189.7,190,497.5,190.1,687.5.4l.4-.4c184.3-184.3,184.3-483,0-667.3h0C659.6,18.1,369.8,18.1,191.1,196.8H191C17.6,370.3,17.6,651.4,191,824.8"
/>
</g>
<circle
id="cur_pos"
class="cls-4 svg-range-indicator"
cx="514"
cy="64.3"
r="18.5"
/>
</g>
<text
id="Min"
class="cls-5 svg-text"
style="text-anchor:start;"
x="195"
y="880"
>
-140
</text>
<text
id="Max"
class="cls-5 svg-text"
style="text-anchor:start;"
x="885"
y="210"
>
410
</text>
</g>
</svg>
Because your spiral is smaller on the inside, if you calculate the length at 0 degrees (or 90, or -90), you'll overshoot it. That is because the total length of the path includes the outside part of the spiral, which is longer, because it's radius is greater. In other words, your logic is correct if the path would have been completely circular. But it's not so you're off by a little bit.
Note that if you change currentDegree to 360, it's almost perfectly placed. That is again because of this radius.
I've used this wonderful package kld-intersections, which can calculate the intersecting points of two SVG shapes.
I first take the midpoint of the circle, then calculate some very long line in the direction I want the circle to have. I calculate the intersections of the path with that line, and I get back an array of intersections.
Now, to know whether to use the closest or the furthest intersection, I sort them by distance to the centre, and check how many times 360 fits between the minimum angle and the desired angle.
Note that the centre point is not perfect, that is why if you change it to -140, the circle will not be at the exact end position. Maybe you can improve on this or - if the design is stable, calculate the point by hand.
const {
ShapeInfo,
Intersection
} = KldIntersections;
function getCentroid(node) {
const bbox = node.getBBox();
return {
x: bbox.x + bbox.width / 2,
y: bbox.y + bbox.height / 2,
};
}
function setDegree(min, max, currentDegree, svg, circle) {
const pathNode = svg.select("path").node();
const centroid = getCentroid(pathNode);
const pathInfo = ShapeInfo.path(pathNode.getAttribute("d"));
// We need to draw a line from the centroid, at the angle we want the
// circle to have.
const currentRadian = (currentDegree / 180) * Math.PI - Math.PI / 2;
const lineEnd = {
// HACK: small offset so the line is never completely vertical
x: centroid.x + 1000 * Math.cos(currentRadian) + Math.random() * 0.01,
y: centroid.y + 1000 * Math.sin(currentRadian),
};
indicatorLine
.attr("x1", centroid.x)
.attr("y1", centroid.y)
.attr("x2", lineEnd.x)
.attr("y2", lineEnd.y);
const line = ShapeInfo.line([centroid.x, centroid.y], [lineEnd.x, lineEnd.y]);
const intersections = Intersection.intersect(pathInfo, line).points;
// Sort the points based on their distance to the centroid
intersections.forEach(
p => p.dist = Math.sqrt((p.x - centroid.x) ** 2 + (p.y - centroid.y) ** 2));
intersections.sort((a, b) => a.dist - b.dist);
// See which intersection we need.
// Iteratively go round the circle until we find the correct one
let i = 0;
while (min + 360 * (i + 1) <= currentDegree) {
i++;
}
const pathPoint = intersections[i];
circle
.attr("cx", pathPoint.x)
.attr("cy", pathPoint.y);
}
const svg = d3.select("svg");
const indicatorLine = svg.append("line").attr("stroke", "red");
const circle = d3.select("#cur_pos");
setDegree(-140, 410, 0, svg, circle);
d3.select("input").on("change", function() {
setDegree(-140, 410, +this.value, svg, circle);
});
p {
font-family: Lato;
}
.cls-3 {
fill: none;
stroke-width: 10px;
stroke: #000;
}
.cls-3,
.cls-4,
.cls-5 {
stroke-miterlimit: 10;
}
.cls-4,
.cls-5 {
stroke-width: 0.25px;
}
.cls-5 {
font-size: 60px;
font-family: ArialMT, Arial;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<script src="https://unpkg.com/kld-intersections"></script>
<label>Value</label> <input type="number" value="0" min="-140" max="410"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1031.1 1010.3" preserveAspectRatio="xMinYMin meet">
<g>
<g id="Spiral_Chart">
<g id="Spiral_Path">
<path
class="cls-3 svg-range-line"
d="M881.5,154.9C679.6-47,352.3-47,150.4,154.9c-195.9,195.9-195.9,513.4,0,709.3,189.7,190,497.5,190.1,687.5.4l.4-.4c184.3-184.3,184.3-483,0-667.3h0C659.6,18.1,369.8,18.1,191.1,196.8H191C17.6,370.3,17.6,651.4,191,824.8"
/>
</g>
<circle
id="cur_pos"
class="cls-4 svg-range-indicator"
cx="514"
cy="64.3"
r="18.5"
/>
</g>
<text
id="Min"
class="cls-5 svg-text"
style="text-anchor:start;"
x="195"
y="880"
>
-140
</text>
<text
id="Max"
class="cls-5 svg-text"
style="text-anchor:start;"
x="885"
y="210"
>
410
</text>
</g>
</svg>

convert regular 2d rectangle coords to trapeze

I started to build a widget that uses svg asset that is a soccer court. I was working with regular 2d rectangle so far and it went well. However i wanted to replace that asset with this one:
I started to prototype on how to calculate the ball position in this kind of svg and its not going well. I guess that what i need is some kind of conversion from regular 2d rectangle model to something else that would account trapeze figure.
Maybe someone could help with understanding how its done. Lets say i have following coords {x: 0.2, y: 0.2} which means i have to put the ball in 20% of width of court and 20% of its height. How do i do in this example?
EDIT #1
I read an answer posted by MBo and i made effort to rewrite delphi code to JavaScript.I dont know delphi at all but i think it went well, however after trying code out i bumped onto couple of problems:
trapeze is reversed (shorter horizotal line on the bottom), i attempted to fix it but without success, after couple of tries i had this as i wanted but then 0.2, 0.2 coord showed up on the bottom instead of closer to the top.
i am not sure if the calculation works correctly in general, center coord seems odly gravitating towards bottom (at least it is my visual impresion)
I attempted to fix reversed trapeze problem by playing with YShift = Hg / 4; but it causes variety of issues. Would like to know how this works exactly
From what i understand, this script works in a way that you specify longer horizontal line Wd and height Hg and this produces a trapeze for you, is that correct?
EDIT #2
I updated demo snippet, it seems to work in some way, the only problem currently i have is that if i specify
Wd = 600; // width of source
Hg = 200; // height of source
the actuall trapeze is smaller (has less width and height),
also in some weird way manipulating this line:
YShift = Hg / 4;
changes the actuall height of trapeze.
its just then difficult to implement, as if i have been given svg court with certain size i need to be able to provide the actuall size to the function so then coords calculations will be accurate.
Lets say that i will be given court where i know 4 corners and based on that i need to be able to calculate coords. This implementation from my demo snippet, doesnt o it unfortunately.
Anyone could help alter the code or provide better implementation?
EDIT #3 - Resolution
this is final implementation:
var math = {
inv: function (M){
if(M.length !== M[0].length){return;}
var i=0, ii=0, j=0, dim=M.length, e=0, t=0;
var I = [], C = [];
for(i=0; i<dim; i+=1){
I[I.length]=[];
C[C.length]=[];
for(j=0; j<dim; j+=1){
if(i==j){ I[i][j] = 1; }
else{ I[i][j] = 0; }
C[i][j] = M[i][j];
}
}
for(i=0; i<dim; i+=1){
e = C[i][i];
if(e==0){
for(ii=i+1; ii<dim; ii+=1){
if(C[ii][i] != 0){
for(j=0; j<dim; j++){
e = C[i][j];
C[i][j] = C[ii][j];
C[ii][j] = e;
e = I[i][j];
I[i][j] = I[ii][j];
I[ii][j] = e;
}
break;
}
}
e = C[i][i];
if(e==0){return}
}
for(j=0; j<dim; j++){
C[i][j] = C[i][j]/e;
I[i][j] = I[i][j]/e;
}
for(ii=0; ii<dim; ii++){
if(ii==i){continue;}
e = C[ii][i];
for(j=0; j<dim; j++){
C[ii][j] -= e*C[i][j];
I[ii][j] -= e*I[i][j];
}
}
}
return I;
},
multiply: function(m1, m2) {
var temp = [];
for(var p = 0; p < m2.length; p++) {
temp[p] = [m2[p]];
}
m2 = temp;
var result = [];
for (var i = 0; i < m1.length; i++) {
result[i] = [];
for (var j = 0; j < m2[0].length; j++) {
var sum = 0;
for (var k = 0; k < m1[0].length; k++) {
sum += m1[i][k] * m2[k][j];
}
result[i][j] = sum;
}
}
return result;
}
};
// standard soccer court dimensions
var soccerCourtLength = 105; // [m]
var soccerCourtWidth = 68; // [m]
// soccer court corners in clockwise order with center = (0,0)
var courtCorners = [
[-soccerCourtLength/2., soccerCourtWidth/2.],
[ soccerCourtLength/2., soccerCourtWidth/2.],
[ soccerCourtLength/2.,-soccerCourtWidth/2.],
[-soccerCourtLength/2.,-soccerCourtWidth/2.]];
// screen corners in clockwise order (unit: pixel)
var screenCorners = [
[50., 150.],
[450., 150.],
[350., 50.],
[ 150., 50.]
];
// compute projective mapping M from court to screen
// / a b c \
// M = ( d e f )
// \ g h 1 /
// set up system of linear equations A X = B for X = [a,b,c,d,e,f,g,h]
var A = [];
var B = [];
var i;
for (i=0; i<4; ++i)
{
var cc = courtCorners[i];
var sc = screenCorners[i];
A.push([cc[0], cc[1], 1., 0., 0., 0., -sc[0]*cc[0], -sc[0]*cc[1]]);
A.push([0., 0., 0., cc[0], cc[1], 1., -sc[1]*cc[0], -sc[1]*cc[1]]);
B.push(sc[0]);
B.push(sc[1]);
}
var AInv = math.inv(A);
var X = math.multiply(AInv, B); // [a,b,c,d,e,f,g,h]
// generate matrix M of projective mapping from computed values
X.push(1);
M = [];
for (i=0; i<3; ++i)
M.push([X[3*i], X[3*i+1], X[3*i+2]]);
// given court point (array [x,y] of court coordinates): compute corresponding screen point
function calcScreenCoords(pSoccer) {
var ch = [pSoccer[0],pSoccer[1],1]; // homogenous coordinates
var sh = math.multiply(M, ch); // projective mapping to screen
return [sh[0]/sh[2], sh[1]/sh[2]]; // dehomogenize
}
function courtPercToCoords(xPerc, yPerc) {
return [(xPerc-0.5)*soccerCourtLength, (yPerc-0.5)*soccerCourtWidth];
}
var pScreen = calcScreenCoords(courtPercToCoords(0.2,0.2));
var hScreen = calcScreenCoords(courtPercToCoords(0.5,0.5));
// Custom code
document.querySelector('.trapezoid').setAttribute('d', `
M ${screenCorners[0][0]} ${screenCorners[0][1]}
L ${screenCorners[1][0]} ${screenCorners[1][1]}
L ${screenCorners[2][0]} ${screenCorners[2][1]}
L ${screenCorners[3][0]} ${screenCorners[3][1]}
Z
`);
document.querySelector('.point').setAttribute('cx', pScreen[0]);
document.querySelector('.point').setAttribute('cy', pScreen[1]);
document.querySelector('.half').setAttribute('cx', hScreen[0]);
document.querySelector('.half').setAttribute('cy', hScreen[1]);
document.querySelector('.map-pointer').setAttribute('style', 'left:' + (pScreen[0] - 15) + 'px;top:' + (pScreen[1] - 25) + 'px;');
document.querySelector('.helper.NW-SE').setAttribute('d', `M ${screenCorners[3][0]} ${screenCorners[3][1]} L ${screenCorners[1][0]} ${screenCorners[1][1]}`);
document.querySelector('.helper.SW-NE').setAttribute('d', `M ${screenCorners[0][0]} ${screenCorners[0][1]} L ${screenCorners[2][0]} ${screenCorners[2][1]}`);
body {
margin:0;
}
.container {
width:500px;
height:200px;
position:relative;
border:solid 1px #000;
}
.view {
background:#ccc;
width:100%;
height:100%;
}
.trapezoid {
fill:none;
stroke:#000;
}
.point {
stroke:none;
fill:red;
}
.half {
stroke:none;
fill:blue;
}
.helper {
fill:none;
stroke:#000;
}
.map-pointer {
background-image:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgaWQ9IkxheWVyXzEiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDUxMiA1MTI7IiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj48Zz48cGF0aCBkPSJNMjU1LjksNmMtMjEuNywwLTQzLjQsNS4zLTYyLjMsMTZjLTMzLjksMTkuMi01Ny45LDU1LjMtNjEuOSw5NC4xYy0zLjcsMzYuMSw4LjksNzEuOCwyMiwxMDUuNyAgIGMxNS4xLDM4LjksMTAyLjEsMjI4LjksMTAyLjEsMjI4LjlzODcuNi0xOTEuNCwxMDIuOC0yMzAuOWMxMy4xLTM0LjIsMjUuNy03MC4yLDIxLjItMTA2LjVjLTUuMi00Mi4xLTM0LjctNzkuOS03My42LTk2LjggICBDMjkwLjUsOS41LDI3My4yLDYsMjU1LjksNnogTTI1NS45LDE4OS44Yy0zMywwLTU5LjgtMjYuOC01OS44LTU5LjhzMjYuOC01OS44LDU5LjgtNTkuOFMzMTUuNyw5NywzMTUuNywxMzAgICBTMjg5LDE4OS44LDI1NS45LDE4OS44eiIvPjxwYXRoIGQ9Ik0yOTIuMiwzOTcuMWMtNC4xLDguOS03LjksMTcuMi0xMS40LDI0LjdjMzYuOCwzLjYsNjMuNiwxNS4yLDYzLjYsMjguOGMwLDE2LjYtMzkuNiwzMC04OC40LDMwICAgYy00OC44LDAtODguNC0xMy40LTg4LjQtMzBjMC0xMy42LDI2LjgtMjUuMiw2My41LTI4LjhjLTMuNS03LjQtNy40LTE1LjgtMTEuNC0yNC43Yy02MC4yLDYuMy0xMDQuNSwyNy45LTEwNC41LDUzLjUgICBjMCwzMC42LDYzLjEsNTUuNCwxNDAuOCw1NS40czE0MC44LTI0LjgsMTQwLjgtNTUuNEMzOTYuOCw0MjUsMzUyLjQsNDAzLjQsMjkyLjIsMzk3LjF6IiBpZD0iWE1MSURfMV8iLz48L2c+PC9zdmc+');
display:block;
width:32px;
height:32px;
background-repeat:no-repeat;
background-size:32px 32px;
position:absolute;
opacity:.3;
}
<div class="container">
<svg class="view">
<path class="trapezoid"></path>
<circle class="point" r="3"></circle>
<circle class="half" r="3"></circle>
<path class="helper NW-SE"></path>
<path class="helper SW-NE"></path>
</svg>
<span class="map-pointer"></span>
</div>
You are looking for a projective mapping from (x,y) in the court plane to (u,v) in the screen plane. A projective mapping works like this:
Append 1 to the court coordinates to get homogenous coordinates (x,y,1)
Multiply these homogenous coordinates with an appropriate 3x3 matrix M from the left to get homogenous coordinates (u',v',l) of the screen pixel
Dehomogenize the coordinates to get the actual screen coordinates (u,v) = (u'/l, v'/l)
The appropriate matrix M can be computed from solving the cooresponding equations for e.g. the corners. Choosing the court center to coincide with origin and the the x-axis pointing along the longer side and measuring the corner coordinates from your image, we get the following corresponding coordinates for a standard 105x68 court:
(-52.5, 34) -> (174, 57)
( 52.5, 34) -> (566, 57)
( 52.5,-34) -> (690,214)
(-52.5,-34) -> ( 50,214)
Setting up the equations for a projective mapping with matrix
/ a b c \
M = ( d e f )
\ g h 1 /
leads to a linear system with 8 equations and 8 unknowns, since each point correspondence (x,y) -> (u,v) contributes two equations:
x*a + y*b + 1*c + 0*d + 0*e + 0*f - (u*x)*g - (u*y)*h = u
0*a + 0*b + 0*c + x*d + y*e + 1*f - (v*x)*g - (v*y)*h = v
(You get these two equations by expanding M (x y 1)^T = (l*u l*v l*1)^T into three equations and substituting the value for l from the third equation into the first two equations.)
The solution for the 8 unknowns a,b,c,...,h put into the matrix gives:
/ 4.63 2.61 370 \
M = ( 0 -1.35 -116.64 )
\ 0 0.00707 1 /
So given e.g. the court center as {x: 0.5, y: 0.5} you must first transform it into the above described coordinate system to get (x,y) = (0,0). Then you must compute
/ x \ / 4.63 2.61 370 \ / 0 \ / 370 \
M ( y ) = ( 0 -1.35 -116.64 ) ( 0 ) = ( 116.64 )
\ 1 / \ 0 0.00707 1 / \ 1 / \ 1 /
By dehomogenizing you get the screen coordinates of the center as
(u,v) = (370/1, 116.64/1) ~= (370,117)
A JavaScript implementation could look like this:
// using library https://cdnjs.cloudflare.com/ajax/libs/mathjs/3.2.1/math.js
// standard soccer court dimensions
var soccerCourtLength = 105; // [m]
var soccerCourtWidth = 68; // [m]
// soccer court corners in clockwise order with center = (0,0)
var courtCorners = [
[-soccerCourtLength/2., soccerCourtWidth/2.],
[ soccerCourtLength/2., soccerCourtWidth/2.],
[ soccerCourtLength/2.,-soccerCourtWidth/2.],
[-soccerCourtLength/2.,-soccerCourtWidth/2.]];
// screen corners in clockwise order (unit: pixel)
var screenCorners = [
[174., 57.],
[566., 57.],
[690.,214.],
[ 50.,214.]];
// compute projective mapping M from court to screen
// / a b c \
// M = ( d e f )
// \ g h 1 /
// set up system of linear equations A X = B for X = [a,b,c,d,e,f,g,h]
var A = [];
var B = [];
var i;
for (i=0; i<4; ++i)
{
var cc = courtCorners[i];
var sc = screenCorners[i];
A.push([cc[0], cc[1], 1., 0., 0., 0., -sc[0]*cc[0], -sc[0]*cc[1]]);
A.push([0., 0., 0., cc[0], cc[1], 1., -sc[1]*cc[0], -sc[1]*cc[1]]);
B.push(sc[0]);
B.push(sc[1]);
}
var AInv = math.inv(A);
var X = math.multiply(AInv, B); // [a,b,c,d,e,f,g,h]
// generate matrix M of projective mapping from computed values
X.push(1);
M = [];
for (i=0; i<3; ++i)
M.push([X[3*i], X[3*i+1], X[3*i+2]]);
// given court point (array [x,y] of court coordinates): compute corresponding screen point
function calcScreenCoords(pSoccer) {
var ch = [pSoccer[0],pSoccer[1],1]; // homogenous coordinates
var sh = math.multiply(M, ch); // projective mapping to screen
return [sh[0]/sh[2], sh[1]/sh[2]]; // dehomogenize
}
function courtPercToCoords(xPerc, yPerc) {
return [(xPerc-0.5)*soccerCourtLength, (yPerc-0.5)*soccerCourtWidth];
}
var pScreen = calcScreenCoords(courtPercToCoords(0.2,0.2))
To make specific pespective projection that has axial symmetry and maps rectangle to isosceles trapezoid, we can build simpler model as I described here.
Let we want to map rectangle with coordinates (0,0)-(SrcWdt, SrcHgt) with axial line at SrcWdt/2
into region with axial line at DstWdt/2 and coordinates of right corners RBX, RBY, RTX, RTY
Here we need (partial) perspective transformation:
X' = DstXCenter + A * (X - XCenter) / (H * Y + 1)
Y' = (RBY + E * Y) / (H * Y + 1)
and we can calculate coefficients A, E, H without solving of eight linear equation system using coordinates of two corners of trapezoid.
Here is demonstration with Delphi code which finds coefficients and calculates mapping of some points into new region (Y-axis down, so perspective sight is from upper edge):
procedure CalcAxialSymPersp(SrcWdt, SrcHgt, DstWdt, RBX, RBY, RTX, RTY: Integer;
var A, H, E: Double);
begin
A := (2 * RBX - DstWdt) / SrcWdt;
H := (A * SrcWdt/ (2 * RTX - DstWdt) - 1) / SrcHgt;
E := (RTY * (H * SrcHgt + 1) - RBY) / SrcHgt;
end;
procedure PerspMap(SrcWdt, DstWdt, RBY: Integer; A, H, E: Double;
PSrc: TPoint; var PPersp: TPoint);
begin
PPersp.X := Round(DstWdt / 2 + A * (PSrc.X - SrcWdt/2) / (H * PSrc.Y + 1));
PPersp.Y := Round((RBY + E * PSrc.Y) / (H * PSrc.Y + 1));
end;
var
Wd, Hg, YShift: Integer;
A, H, E: Double;
Pts: array[0..3] of TPoint;
begin
//XPersp = XPCenter + A * (X - XCenter) / (H * Y + 1)
//YPersp = (YShift + E * Y) / (H * Y + 1)
Wd := Image1.Width;
Hg := Image1.Height;
YShift := Hg div 4;
CalcAxialSymPersp(Wd, Hg, Wd,
Wd * 9 div 10, YShift, Wd * 8 div 10, Hg * 3 div 4,
A, H, E);
//map 4 corners
PerspMap(Wd, Wd, YShift, A, H, E, Point(Wd, 0), Pts[0]);
PerspMap(Wd, Wd, YShift, A, H, E, Point(Wd, Hg), Pts[1]);
PerspMap(Wd, Wd, YShift, A, H, E, Point(0, Hg), Pts[2]);
PerspMap(Wd, Wd, YShift, A, H, E, Point(0, 0), Pts[3]);
//draw trapezoid
Image1.Canvas.Brush.Style := bsClear;
Image1.Canvas.Polygon(Pts);
//draw trapezoid diagonals
Image1.Canvas.Polygon(Slice(Pts, 3));
Image1.Canvas.Polygon([Pts[1], Pts[2], Pts[3]]);
//map and draw central point
PerspMap(Wd, Wd, YShift, A, H, E, Point(Wd div 2, Hg div 2), Pts[0]);
Image1.Canvas.Ellipse(Pts[0].X - 3, Pts[0].Y - 3, Pts[0].X + 4, Pts[0].Y + 4);
//map and draw point at (0.2,0.2)
PerspMap(Wd, Wd, YShift, A, H, E, Point(Wd * 2 div 10, Hg * 2 div 10), Pts[0]);
Image1.Canvas.Ellipse(Pts[0].X - 3, Pts[0].Y - 3, Pts[0].X + 4, Pts[0].Y + 4);
I have implemented it in plain HTML and JavaScript. You have to adjust the variables to your need. A and B are the lengths of small and large parallel sides and H is the height of trapeze. x0, y0 are the co-ordinates of left-bottom corner of the field. If it works out for you I will explain the math.
jQuery(function($){
var $field2d = $('.field2d'), $ball = $('.ball');
$field2d.on('mousemove', function(e){
var pos = translateBallPosition(e.offsetX, e.offsetY);
$ball.css({left: pos.x, top: pos.y});
});
var FB = {x0: 50, y0: 215, B: 640, A: 391, H: 158, P: 0};
FB.Wd = $field2d.width();
FB.Ht = $field2d.height();
FB.P = FB.B * FB.H / (FB.B - FB.A);
function translateBallPosition(X, Y){
var x = X / FB.Wd * FB.B, y = (FB.Ht - Y) / FB.Ht * FB.H;
y = y * FB.B * FB.H / (FB.A * FB.H + y * (FB.B - FB.A));
x = x / FB.P * (FB.P - y) + y * FB.B / FB.P / 2;
return {x: FB.x0 + x, y: FB.y0 - y};
}
});
.field2d {
position: relative;
border: 1px dashed gray;
background: #b0fdb5;
width: 400px;
height: 200px;
margin: 5px auto;
cursor: crosshair;
text-align: center;
}
.field3d {
position: relative;
width: 743px;
margin: auto;
}
.field3d>img {
width: 100%;
height: auto;
}
.ball {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
background: red;
border-radius: 10px;
margin: -20px 0 0 -10px;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div class="field3d">
<img src="https://i.stack.imgur.com/ciekU.png" />
<div class="ball"></div>
</div>
<div class="field2d">
Hover over this div to see corresponding ball position
</div>

JavaScript - Get average color from a certain area of an image

I need to get the average color from a rectangle area of an image using JavaScript.
I tried using tracking.js but it doesn't allow to specify areas instead of single pixels.
If you need to get the average color of a single pixel, rather than the color of a rectangular area, please take a look at this other question:
👉 Get pixel color from canvas, on mouseover
As you say that you need to get the color from a rectangle area from an image, I will assume you mean that you need to get the average color of a given area, and not the color of a single pixel.
Anyway, both are done in a very similar way:
🔍 Getting The Color/Value of A Single Pixel from An Image or Canvas
To get the color of a single pixel, you would first draw that image to a canvas:
const image = document.getElementById('image');
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const width = image.width;
const height = image.height;
canvas.width = width;
canvas.height = height;
context.drawImage(image, 0, 0, width, height);
And then get the value of a single pixel like this:
const data = context.getImageData(X, Y, 1, 1).data;
// RED = data[0]
// GREEN = data[1]
// BLUE = data[2]
// ALPHA = data[3]
✂️ Getting The Average Color/Value from A Region On An Image or Canvas
You need to use this same CanvasRenderingContext2D.getImageData() to get the values of a wider (multi-pixel) area, which you do by changing its third and fourth params. The signature of that function is:
ImageData ctx.getImageData(sx, sy, sw, sh);
sx: The x coordinate of the upper left corner of the rectangle from which the ImageData will be extracted.
sy: The y coordinate of the upper left corner of the rectangle from which the ImageData will be extracted.
sw: The width of the rectangle from which the ImageData will be extracted.
sh: The height of the rectangle from which the ImageData will be extracted.
You can see it returns a ImageData object, whatever that is. The important part here is that that object has a .data property which contains all our pixel values.
However, note that .data property is a 1-dimension Uint8ClampedArray, which means that all the pixel's components have been flattened, so you are getting something that looks like this:
Let's say you have a 2x2 image like this:
RED PIXEL | GREEN PIXEL
BLUE PIXEL | TRANSPARENT PIXEL
Then, you will get them like this:
[ 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0 ]
| RED PIXEL | GREEN PIXEL | BLUE PIXEL | TRANSPAERENT PIXEL |
| 1ST PIXEL | 2ND PIXEL | 3RD PIXEL | 4TH PIXEL |
✨ Let's See It in Action
const avgSolidColor = document.getElementById('avgSolidColor');
const avgAlphaColor = document.getElementById('avgAlphaColor');
const avgSolidWeighted = document.getElementById('avgSolidWeighted');
const avgSolidColorCode = document.getElementById('avgSolidColorCode');
const avgAlphaColorCode = document.getElementById('avgAlphaColorCode');
const avgSolidWeightedCOde = document.getElementById('avgSolidWeightedCode');
const brush = document.getElementById('brush');
const image = document.getElementById('image');
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const width = image.width;
const height = image.height;
const BRUSH_SIZE = brush.offsetWidth;
const BRUSH_CENTER = BRUSH_SIZE / 2;
const MIN_X = image.offsetLeft + 4;
const MAX_X = MIN_X + width - BRUSH_SIZE;
const MIN_Y = image.offsetTop + 4;
const MAX_Y = MIN_Y + height - BRUSH_SIZE;
canvas.width = width;
canvas.height = height;
context.drawImage(image, 0, 0, width, height);
function sampleColor(clientX, clientY) {
const brushX = Math.max(Math.min(clientX - BRUSH_CENTER, MAX_X), MIN_X);
const brushY = Math.max(Math.min(clientY - BRUSH_CENTER, MAX_Y), MIN_Y);
const imageX = brushX - MIN_X;
const imageY = brushY - MIN_Y;
let R = 0;
let G = 0;
let B = 0;
let A = 0;
let wR = 0;
let wG = 0;
let wB = 0;
let wTotal = 0;
const data = context.getImageData(imageX, imageY, BRUSH_SIZE, BRUSH_SIZE).data;
const components = data.length;
for (let i = 0; i < components; i += 4) {
// A single pixel (R, G, B, A) will take 4 positions in the array:
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
// Update components for solid color and alpha averages:
R += r;
G += g;
B += b;
A += a;
// Update components for alpha-weighted average:
const w = a / 255;
wR += r * w;
wG += g * w;
wB += b * w;
wTotal += w;
}
const pixelsPerChannel = components / 4;
// The | operator is used here to perform an integer division:
R = R / pixelsPerChannel | 0;
G = G / pixelsPerChannel | 0;
B = B / pixelsPerChannel | 0;
wR = wR / wTotal | 0;
wG = wG / wTotal | 0;
wB = wB / wTotal | 0;
// The alpha channel need to be in the [0, 1] range:
A = A / pixelsPerChannel / 255;
// Update UI:
requestAnimationFrame(() => {
brush.style.transform = `translate(${ brushX }px, ${ brushY }px)`;
avgSolidColorCode.innerText = avgSolidColor.style.background
= `rgb(${ R }, ${ G }, ${ B })`;
avgAlphaColorCode.innerText = avgAlphaColor.style.background
= `rgba(${ R }, ${ G }, ${ B }, ${ A.toFixed(2) })`;
avgSolidWeightedCode.innerText = avgSolidWeighted.style.background
= `rgb(${ wR }, ${ wG }, ${ wB })`;
});
}
document.onmousemove = (e) => sampleColor(e.clientX, e.clientY);
sampleColor(MIN_X, MIN_Y);
body {
margin: 0;
height: 100vh;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
cursor: crosshair;
font-family: monospace;
}
#image {
border: 4px solid white;
border-radius: 2px;
box-shadow: 0 0 32px 0 rgba(0, 0, 0, .25);
width: 150px;
box-sizing: border-box;
}
#brush {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
width: 50px;
height: 50px;
background: magenta;
mix-blend-mode: exclusion;
}
#samples {
position: relative;
list-style: none;
padding: 0;
width: 250px;
}
#samples::before {
content: '';
position: absolute;
top: 0;
left: 27px;
width: 2px;
height: 100%;
background: black;
border-radius: 1px;
}
#samples > li {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 56px;
}
#samples > li + li {
margin-top: 8px;
}
.sample {
position: absolute;
top: 50%;
left: 16px;
transform: translate(0, -50%);
display: block;
width: 24px;
height: 24px;
border-radius: 100%;
box-shadow: 0 0 16px 4px rgba(0, 0, 0, .25);
margin-right: 8px;
}
.sampleLabel {
font-weight: bold;
margin-bottom: 8px;
}
.sampleCode {
}
<img id="image" src="data:image/gif;base64,R0lGODlhSwBLAPEAACMfIO0cJAAAAAAAACH/C0ltYWdlTWFnaWNrDWdhbW1hPTAuNDU0NTUAIf4jUmVzaXplZCBvbiBodHRwczovL2V6Z2lmLmNvbS9yZXNpemUAIfkEBQAAAgAsAAAAAEsASwAAAv+Uj6mb4A+QY7TaKxvch+MPKpC0eeUUptdomOzJqnLUvnFcl7J6Pzn9I+l2IdfII8DZiCnYsYdK4qRTptAZwQKRVK71CusOgx2nFRrlhMu+33o2NEalC6S9zQvfi3Mlnm9WxeQ396F2+HcQsMjYGEBRVbhy5yOp6OgIeVIHpEnZyYCZ6cklKBJX+Kgg2riqKoayOWl2+VrLmtDqBptIOjZ6K4qAeSrL8PcmHExsgMs2dpyIxPpKvdhM/YxaTMW2PGr9GP76BN3VHTMurh7eoU14jsc+P845Vn6OTb/P/I68iYOfwGv+JOmRNHBfsV5ujA1LqM4eKDoNvXyDqItTxYX/DC9irKBlIhkKGPtFw1JDiMeS7CqWqySPZcKGHH/JHGgIpb6bCl1O0LmT57yCOqoI5UcU0YKjPXmFjMm0ZQ4NIVdGBdZRi9WrjLxJNMY1Yr4dYeuNxWApl1ALHb+KDHrTV1owlriedJgSr4Cybu/9dFiWYAagsqAGVkkzaZTAuqD9ywKWMUG9dCO3u2zWpVzIhpW122utZlrHnTN+Bq2Mqrlnqh8CQ+0Mrq3Kc++q7eo6dlB3rLuh3abPVbbbI2mxBdhWdsZhid8cr0oy9F08q0k5FXSadiyL1mF5z51a8VsQOp3/LlodkBfzmzWf2bOrtfzr48k/1hupDaLa9rUbO+zlwndfaOCURAXRNaCBqBT2BncJakWfTzSYkmCEFr60RX0V8sKaHOltCBJ1tAAFYhHaVVbig3jxp0IBADs=" >
<div id="brush"></div>
<ul id="samples">
<li>
<span class="sample" id="avgSolidColor"></span>
<div class="sampleLabel">avgSolidColor</div>
<div class="sampleCode" id="avgSolidColorCode">rgb(-, -, -)</div>
</li>
<li>
<span class="sample" id="avgAlphaColor"></span>
<div class="sampleLabel">avgAlphaColor</div>
<div class="sampleCode" id="avgAlphaColorCode">rgba(-, -, -, -)</div>
</li>
<li>
<span class="sample" id="avgSolidWeighted"></span>
<div class="sampleLabel">avgSolidWeighted</div>
<div class="sampleCode" id="avgSolidWeightedCode">rgba(-, -, -, -)</div>
</li>
</ul>
⚠️ Note I'm using a small data URI to avoid Cross-Origin issues if I include an external image or an answer that is larger than allowed if I try to use a longer data URI.
🕵️ These average colors look weird, don't they? (#SMT's comment)
If you move the brush to the upper-left corner, you will see avgSolidColor is almost black. That's because most pixels in that area are totally transparent, so their value is exactly or quite close to 0, 0, 0, 255. That means that for each of those we process, R, G and B don't change or change very little, while pixelsPerChannel still takes them into account, so we end up dividing a small number (as we are adding 0 for most of them) by a large one (the total number of pixels in the brush), which gives us a value quite close to 0 (black).
For example, if we have two pixels, 0, 0, 0, 255 and 255, 0, 0, 0, by looking at them we might expect the average for the R channel to be 255 (as one of them is completely transparent). However, it will be (0 + 255) / 2 | 1 = 127. But don't worry, we will see how to do that next.
On the other hand, avgAlphaColor looks grey. Well, that's actually not true, it just looks grey because we are now using the alpha channel, which makes it semitransparent and allows us to see the background of the page, which in this case is white.
🎨 Alpha-weighted average (#SMT's comment's solution)
Then, what can we do to fix this? Well, it turns out we just need to use the alpha channel as the weight for our (now weighted) average:
That means that if a pixel is r, g, b, a, where a is in the interval [0, 255], we will update our variables like so:
const w = a / 255; // w is in the interval [0, 1]
wR += r * w;
wG += g * w;
wB += b * w;
wTotal += w;
Note how the more transparent a pixel is (w closer to 0), the less we care about its values in our calculations.
Not sure whether this technique is feasible in the DOM with canvases and ImageData, but back in the day with Flash and Actionscript, there was a great shortcut.
In my use case, the regions in question were uniform: all regions were squares of the same size, e.g. I had a 1280x720
BitmapData (equivalent of ImageData) coming in from the webcam or a video, and I needed the average colors to make a grid of e.g. 16x9 regions representing the input image.
What I did was this: draw the entire input image into a bitmap with size values in pixels equal to the number of region columns and rows. E.g. 1280x720px image drawn into a 16x9px bitmap.
Then simply use native methods to get the color of exactly one pixel in the scaled-down image.
This method was as precise as doing it manually, and much much faster, also the code was much simpler.

Categories