Creating a 30s timer display with SVG and JS - javascript

I am in the process of trying to create a 30s countdown timer display using SVG and a spot of JS. The idea is simple
Draw the face of the countdown clock as an SVG circle
Inside it draw a closed SVG path in the form of the sector of circle
Use window.requestAnimationFrame to update that sector at one second intervals
My effort is shown below. While it works the final result is far from being smooth and convincing.
When the spent time gets into the second quadrant of the circle the sector appears to swell past the circumference
When it is in the third and fourth quadrant it appears to detach from the circumference.
What am I doing wrong here and how could it be improved?
var _hold = {tickStart:0,stopTime:30,lastDelta:0};
String.prototype.format = function (args)
{
var newStr = this,key;
for (key in args) {newStr = newStr.replace('{' + key + '}',args[key]);}
return newStr;
};
Boolean.prototype.intval = function(places)
{
places = ('undefined' == typeof(places))?0:places;
return (~~this) << places;
};
function adjustSpent(timeStamp)
{
if (0 === _hold.tickStart) _hold.tickStart = timeStamp;
var delta = Math.trunc((timeStamp - _hold.tickStart)/1000);
if (_hold.lastDelta < delta)
{
_hold.lastDelta = delta;
var angle = 2*Math.PI*(delta/_hold.stopTime),
dAngle = 57.2958*angle,
cx = cy = 50,
radius = 38,
top = 12,
x = cx + radius*Math.sin(angle),
y = cy - radius*Math.cos(angle),
large = (180 < dAngle).intval();
var d = (360 <= dAngle)?"M50,50 L50,12 A38,38 1 0,1 51,12 z":"M50,50 L50,12 A38,38 1 {ll},1 {xx},{yy} z".format({ll:large,xx:x,yy:y});
var spent = document.getElementById('spent');
if (spent) spent.setAttribute("d",d);
}
if (delta < _hold.stopTime) window.requestAnimationFrame(adjustSpent);
}
window.requestAnimationFrame(adjustSpent);
timer
{
position:absolute;
height:20vh;
width:20vh;
border-radius:100%;
background-color:orange;
left:calc(50vw - 5vh);
top:15vh;
}
#clockface{fill:white;}
#spent{fill:#6683C2;}
<timer>
<svg width="20vh" height="20vh" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="38" id="clockface"></circle>
<path d="M50,50 L50,12 A38,38 1 0,1 51,12 z" id="spent"></path>
</svg>
</timer>

A posible solution would be using a stroke animation like this:
The blue circle has a radius of 38/2 = 19
The stroke-width of the blue circle is 38 giving the illusion of a circle of 38 units.
Please take a look at the path: it's also a circle of radius = 19.
svg {
border: 1px solid;
height:90vh;
}
#clockface {
fill: silver;
}
#spent {
fill:none;
stroke: #6683c2;
stroke-width: 38px;
stroke-dasharray: 119.397px;
stroke-dashoffset: 119.397px;
animation: dash 5s linear infinite;
}
#keyframes dash {
to {
stroke-dashoffset: 0;
}
}
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="38" id="clockface"></circle>
<path d="M50,31 A19,19 1 0,1 50,69 A19,19 1 0,1 50,31" id="spent"></path>
</svg>
In this case I've used css animations but you can control the value for stroke-dashoffset with JavaScript.
The value for stroke-dasharray was obtained using spent.getTotalLength()
If you are not aquainted with stroke animations in SVG please read How SVG Line Animation Works

Related

JavaScript to move a gif along an SVG path on Scroll not working

I want to move an animated gif along an svg path on scroll, I've been trying to adapt Text moving along an SVG <textPath> but it's not working. I'd like to know what the best solution is.
<svg id="text-container" viewBox="0 0 1000 194" xmlns="http://www.w3.org/2000/svg">
<path id="text-curve" d="M0 100s269.931 86.612 520 0c250.069-86.612 480 0 480 0" fill="none"/>
<text y="40" font-size="2.1em">
<textPath id="text-path" href="#text-curve">
<img src="../imagesIndex/originals/dragon.gif" height="194px"/>
</textPath>
</text>
</svg>
I can get Text moving along the SVG curve but not the image. I've tried expanding the SVG viewbox, shrinking the image with the defined height above, I've tried changing the SVG <textPath to <path it didn't work. I'm getting nowhere.
The image appears, but it won't move along the SVG's path.
Here's the Javascript
<script>
console.clear();
var textPath = document.querySelector('#text-path');
var textContainer = document.querySelector('#text-container');
var path = document.querySelector( textPath.getAttribute('href') );
var pathLength = path.getTotalLength();
console.log(pathLength);
function updateTextPathOffset(offset){
textPath.setAttribute('startOffset', offset);
}
updateTextPathOffset(pathLength);
function onScroll(){
requestAnimationFrame(function(){
var rect = textContainer.getBoundingClientRect();
var scrollPercent = rect.y / window.innerHeight;
console.log(scrollPercent);
updateTextPathOffset( scrollPercent * 2 * pathLength );
});
}
window.addEventListener('scroll',onScroll);
</script>
Apologies if this question is a duplicate. I do have a Greensock GSAP, ShockinglyGreen subscription, all libraries available, but I'm yet to dig into it.
Here's some sample code to position an SVG <image> element at a position along a path determined by the page scroll.
var path = document.querySelector('#text-curve');
var cat = document.querySelector('#cat');
var catWidth = 40;
var catHeight = 40;
function updateImagePosition(offset) {
let pt = path.getPointAtLength(offset * path.getTotalLength());
cat.setAttribute("x", pt.x - catWidth/2);
cat.setAttribute("y", pt.y - catHeight/2);
}
// From: https://stackoverflow.com/questions/2387136/cross-browser-method-to-determine-vertical-scroll-percentage-in-javascript
function getScrollFraction() {
var h = document.documentElement,
b = document.body,
st = 'scrollTop',
sh = 'scrollHeight';
return (h[st]||b[st]) / ((h[sh]||b[sh]) - h.clientHeight);
}
function onScroll() {
updateImagePosition( getScrollFraction() );
}
updateImagePosition(0);
window.addEventListener('scroll', onScroll);
body {
min-height: 1000px;
}
svg {
display: block;
position: sticky;
top: 20px;
}
<svg id="text-container" viewBox="0 0 1000 194">
<path id="text-curve" d="M0 100s269.931 86.612 520 0c250.069-86.612 480 0 480 0" fill="none" stroke="gold"/>
<image id="cat" x="0" y="100" xlink:href="https://placekitten.com/40/40"/>
</svg>

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>

Percentage animation on the circle

I have a code that displays the percentage as a circle. Is it possible to do something to make the animation start from the top, to the right, and not like now, it starts from the right. Is it possible to round this line? Is there any other, better code to do something like that? I'm only interested in vanillaJS.
var circle = document.querySelector('circle');
var radius = circle.r.baseVal.value;
var circumference = radius * 2 * Math.PI;
circle.style.strokeDasharray = circumference;
circle.style.strokeDashoffset = circumference;
function setProgress(percent) {
var offset = circumference - percent / 100 * circumference;
circle.style.strokeDashoffset = offset;
}
setProgress(60);
<svg class="progress-ring" width="120" height="120">
<circle class="progress-ring__circle" stroke="#000" stroke-width="8" fill="transparent" r="56" cx="60" cy="60">
</svg>
As I've commented you may rotate the svg element transform:rotate(-90deg). Alternatively you may rotate the circle. Also you can use a path instead of a circle and make it start at the top.
If you want to use a path this is how you do it:
In this case the path starts at the top M60,4
Next comes an arc where both radiuses are 56. The first arc ends at 60,116
Follows a second arc A56,56,0 0 1 60,4 and finnaly you close the path z
For the circumference you don't need to know the radius. You can do var circumference = circle.getTotalLength(); where getTotalLength is a method that is returning the total length of a path.
var circle = document.querySelector('path');
var circumference = circle.getTotalLength();
circle.style.strokeDasharray = circumference;
circle.style.strokeDashoffset = circumference;
function setProgress(percent) {
var offset = circumference - percent / 100 * circumference;
circle.style.strokeDashoffset = offset;
}
setProgress(60);
<svg class="progress-ring" width="120" height="120">
<path fill="none" class="progress-ring__circle" stroke="black" stroke-linecap="round" stroke-width="8" d="M60,4A56,56,0 0 1 60,116A56,56,0 0 1 60,4z" />
</svg>
First of all, Welcome on StackOverflow.
I think you have a trigonometry problem here. You have a trigonometric circle with your code and it start like others trigonometric circles at the right :
A simple solution is to rotate your circle with CSS :
svg{
transform: rotate(-90deg);
}

SVG Rectangle not rotating/translating group properly when using SVG transform "matrix()" function

I am attempting to animate a group of SVG objects. What should be happening is the 4 coloured rectangle should move to the right, while spinning around it's center axis, but what is actually happening is a rotation around point 0,0 on the screen. Can someone help me understand what I am doing wrong?
Here is the HTML/SVG
<head></head>
<body>
<button id="startBtn">START/STOP</button>
<button id="resetBtn">RESET</button>
<svg id="thesvg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1362.98 768">
<g id="rectangle">
<rect style="fill: red;" x="0" y="0" width="50" height="50" />
<rect style="fill: green;" x="50" y="0" width="50" height="50" />
<rect style="fill: yellow;" x="0" y="50" width="50" height="50" />
<rect style="fill: blue;" x="50" y="50" width="50" height="50" />
</g>
</svg>
To do this I am running the following function
var timer = null;
var started = false;
var x = 0;
var y = 0;
// Center of rectangle
var cx=50, cy=50;
var angle = 0;
/**
* Ran every tick. Should make the rectangle move diagonally right/down
* while spinning around it's center
*/
function running(){
updateRectanglePosition();
// Get the matrix of the parent element
var rect = document.getElementById('rectangle');
var ctm = rect.parentNode.getScreenCTM();
var matrix = new DOMMatrix([ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]);
// Translate the center of the group to 0,0 of parent matrix
matrix = matrix.translate(-(cx), -(cy));
// Rotate around this point
matrix = matrix.rotate(angle % 360);
// Translate to actual x,y position
matrix = matrix.translate(x , y);
rect.setAttribute('transform', matrix.inverse().toString());
if(timer){
timer = setTimeout(running, 100);
}
}
// Updates rectangle position every frame
function updateRectanglePosition()
{
x += 1;
y += 1;
angle = (angle + 10);
}
Example https://codepen.io/comfydemon/pen/jObYXYL
The way I had to modify this was to rotate the translation vector by the opposite of the amount I rotated the shape to "undo" the coordinate system that was changed by the rotation
// Translate the element to the x,y pos
let translation = new DOMMatrix();
let movement = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
// take the x and y translation values and rotate them to the opposite of the rotation of the shape
// toRadians just converts deg to rad
translation = translation.translate(movement * Math.cos(toRadians(360 - angle)),movement * Math.sin(toRadians(360 - angle)));
The original question says that 4 rectangles are only rotating but not translating. Copying the exact code revealed that 4 boxes are infact rotating and translating as well. You are translating it by only 1 pixel after 100 ms that gave the impression that object is not moving. Try increasing pixel value and as your coordinate system is reversed, you have to use negative value for x-axis to move the object towards right.
See below Working code (Desired result):
<body>
<button id="startBtn">START/STOP</button>
<svg id="thesvg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1362.98 768">
<g id="rectangle">
<rect style="fill: red;" x="0" y="0" width="50" height="50" />
<rect style="fill: green;" x="50" y="0" width="50" height="50" />
<rect style="fill: yellow;" x="0" y="50" width="50" height="50" />
<rect style="fill: blue;" x="50" y="50" width="50" height="50" />
</g>
</svg>
<script>
var timer = null;
var started = false;
var x = 0;
var y = 0;
// Center of rectangle
var cx=50, cy=50;
var angle = 0;
/**
* Ran every tick. Should make the rectangle move diagonally right/down
* while spinning around it's center
*/
function running(){
updateRectanglePosition();
// Get the matrix of the parent element
var rect = document.getElementById('rectangle');
var ctm = rect.parentNode.getScreenCTM();
var matrix = new DOMMatrix([ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]);
// Translate the center of the group to 0,0 of parent matrix
matrix = matrix.translate(50, 50);
// Rotate around this point
matrix = matrix.rotate(angle % 360);
matrix = matrix.translate(x, -250);
// Translate to actual x,y position
//matrix = matrix.translate(x , y);
rect.setAttribute('transform', matrix.inverse().toString());
if(timer){
timer = setTimeout(running, 100);
}
}
// Updates rectangle position every frame
function updateRectanglePosition()
{
x -= 10;
y += 1;
angle = (angle + 10);
}
// Starts and stops the setTimeout timer that runs the animation
function startAnimation() {
started = !started;
if(started){
if(timer){
clearTimeout(timer);
}
timer = setTimeout(running, 100);
}
else {
timer = clearTimeout(timer);
}
}
// Resets the variables
function reset(){
x = 0;
y = 0;
angle = 0;
}
document.getElementById('startBtn').addEventListener('click', startAnimation);
</script>
</body>

Increasing the size of an SVG circle

I came across this fiddle:
http://jsfiddle.net/wz32sy7y/1/
I'm having a hard time understanding how I would expand the circle to have a bigger radius.
I tried change the radius property r, but this desynchronizez the animation.
The radius seems to be some magical number, but I cannot determine how it's calculated.
<svg width="160" height="160" xmlns="http://www.w3.org/2000/svg">
<g>
<title>Layer 1</title>
<circle id="circle" class="circle_animation" r="79.85699"
cy="81" cx="81" stroke-width="8" stroke="#6fdb6f" fill="none"/>
</g>
</svg>
For a given radius r, the circumference of the circle is 2πr.
The values in this fiddle are slightly off due to rounding, but you can verify that the relationship holds by setting new values for the radius and circumference.
There are three places in the fiddle where the circumference is used. Once in the JavaScript:
var initialOffset = '440';
Twice in the CSS:
.circle_animation {
stroke-dasharray: 440; /* this value is the pixel circumference of the circle */
stroke-dashoffset: 440;
transition: all 1s linear;
}
Here is a version of the fiddle where the radius is set to 20 and the circumference to 2 π × 20 = 125.664:
http://jsfiddle.net/6x3rbpfu/1/
Here we set the radius to 50 and the radius to 314.159:
http://jsfiddle.net/6x3rbpfu/2/
The following fiddle will allow you to set the width arbitrarily, using the tag and the "r" attribute, and not changing your CSS every time. Try changing the value in the "r" attribute in the SVG to whatever you like.
https://jsfiddle.net/ma46yjvx/1/
Dash offset animation in SVG works by making a really long dash, using SVG's dashed outline features, and then creeping the border along that path, using an offset in pixels. It makes it look like it is drawing.
So when we scale the radius, we need to scale the amount that we offset the dash per animation step. Thus, using the same magic number the author used (dunno where it comes from, but it works!), we have this:
var time = 10;
var initialOffset = '440';
var i = 1
var r = $(".circle_animation").attr("r"); //Get the radius, so we can know the multiplier
var interval = setInterval(function() {
$('.circle_animation').css(
'stroke-dashoffset',
initialOffset-(i*(initialOffset/time)*(r/69.85699)) //Scale it!
);
$('h2').text(i);
if (i == time) {
clearInterval(interval);
}
i++;
}, 1000);

Categories