Implementing HTML5 canvas gradients in React - javascript

I'm trying to create an app that lets you create a background gradient with two different colors,I'm using React. The first color of the gradient looks as it should, but the second color of the gradient is more of a solid color, with a jagged border. Heres a picture to demonstrate:
My goal is to get something that closer resembles to this:
Where the colors blend.
I'm referring to the MDN docs , and have messed around with the radius and x, y properties. I'm changing the canvas based on prop changes from the parent component, here's my code:
import React, { Component } from "react";
class Canvas extends Component {
componentDidMount() {
const { gradientOne, gradientTwo } = this.props.canvasState.backgroundColor;
this.ctx = this.canvas.getContext("2d");
this.radialGradient = this.ctx.createRadialGradient(
0,
0,
300,
260,
160,
100
);
this.ctx.fillStyle = this.radialGradient;
this.ctx.rect(0, 0, this.canvas.width, this.canvas.height);
this.radialGradient.addColorStop(0, gradientOne);
this.radialGradient.addColorStop(1, gradientTwo);
this.ctx.fill();
}
componentDidUpdate(prevProps, prevState) {
const { gradientOne, gradientTwo } = this.props.canvasState.backgroundColor;
if (prevProps.canvasState.backgroundColor.gradientOne !== gradientOne) {
this.ctx.fillStyle = this.radialGradient;
this.radialGradient.addColorStop(0, gradientOne);
this.ctx.fill();
} else if (
prevProps.canvasState.backgroundColor.gradientTwo !== gradientTwo
) {
this.ctx.fillStyle = this.radialGradient;
this.radialGradient.addColorStop(1, gradientTwo);
this.ctx.fill();
}
}
render() {
return (
<main className="canvasContainer">
<canvas ref={ref => (this.canvas = ref)} id="canvas">
YOUR BROWSER DOESN'T SUPPORT THIS FEATURE :(
</canvas>
</main>
);
}
}
export default Canvas;
Thanks for any help!

Color stops of CanvasGradient (be it linear or radial) can't be modified nor removed.
When you add a new color stop, at the same index than a previous one, it gets placed right after that previous one. So instead of having only two color stops, you have four.
This means that if you have an original gradient like so
<-red -------------------------------------------- green->
and that you add two new color stops blue and yellow at position 0 and 1, you will have something like
<-red[blue -------------------------------- green]yellow->
That is, no gradient between red and blue, nor between yellow and green:
const ctx = canvas.getContext('2d');
ctx.strokeStyle = 'white';
// an horizontal gradient
// 0 is at pixel 50, and 1 at pixel 250 on the x axis
const grad = ctx.createLinearGradient(50,0,250,0);
grad.addColorStop(0, 'red');
grad.addColorStop(1, 'green');
ctx.fillStyle = grad;
// top is two color stops version
ctx.fillRect(0,0,300,70);
// bottom is four color stops version
grad.addColorStop(0, 'blue');
grad.addColorStop(1, 'yellow');
ctx.fillStyle = grad;
ctx.fillRect(0,80,300,70);
// mark color stops
ctx.moveTo(49.5,0);
ctx.lineTo(49.5,150);
ctx.moveTo(249.5,0);
ctx.lineTo(249.5,150);
ctx.stroke();
canvas { border: 1px solid };
<canvas id="canvas"></canvas>
That's what you are doing in your code, since you add two color stops in componentDidMount, and then add more in componentDidUpdate.
To avoid this, simply overwrite your gradient property in componentDidUpdate, so that you start everytime with a new gradient.
const ctx = canvas.getContext('2d');
let radialGradient;
function didMount() {
radialGradient = ctx.createRadialGradient(
0,0,300,
260,160,100
);
radialGradient.addColorStop(0, c1.value);
radialGradient.addColorStop(1, c2.value);
ctx.fillStyle = radialGradient;
ctx.fillRect(0,0,canvas.width,canvas.height);
}
function didUpdate() {
// reset radialGradient to a new one
radialGradient = ctx.createRadialGradient(
0,0,300,
260,160,100
);
radialGradient.addColorStop(0, c1.value);
radialGradient.addColorStop(1, c2.value);
ctx.fillStyle = radialGradient;
ctx.fillRect(0,0,canvas.width,canvas.height);
}
didMount();
c1.oninput = c2.oninput = didUpdate;
canvas { border: 1px solid };
<input id="c1" type="color" value="#22CC22">
<input id="c2" type="color" value="#FF2222">
<canvas id="canvas"></canvas>

Related

Visualize Data as Color Bar in Javascript

I'm trying to create a data visualization with pure JS. I can't find any examples of how to do this that don't rely on third-party libraries.
Let's say you have some data:
data = {
green = 20%;
yellow = 30%;
red = 50%;
}
And you have created a rectangle in HTML that you can fill with color.
How can I use JS to fill the colors according to the percentage of each color? As you'll see in my code below, right now I've just hard-coded in the color percentages. But I want it to be dynamic so it updates when the data changes.
Here's what I have so far:
const drawColors = () => {
const colorBar = document.getElementById("color-bar");
const context = colorBar.getContext('2d');
// Hard coding percentages
context.beginPath();
context.fillStyle = "green";
context.fillRect(0, 0, 100, 100);
context.fillStyle = "yellow"
context.fillRect(100, 0, 100, 100);
context.fillStyle = "red"
context.fillRect(200, 0, 100, 100);
}
drawColors();
<canvas id="color-bar" width="300" height="100">
Assuming you validate somewhere else that percentages values all add up to 100, iterate through the percentages object and fill like this:
const percentages = {
green: 50,
yellow: 30,
red: 20
}
const drawColors = () => {
const colorBar = document.getElementById("color-bar")
const context = colorBar.getContext('2d')
//Calculate pixels from percentage based on canvas width.
const toPixels = percentage => (percentage*colorBar.width)/100
context.beginPath()
let addedColors = 0 //To set the starting point of each fill.
Object.keys(percentages).forEach(color => {
let widthToPaint = toPixels(percentages[color])
context.fillStyle = color
context.fillRect(addedColors, 0, widthToPaint , 100)
addedColors+= widthToPaint
})
}
drawColors()
<canvas id="color-bar" width="300" height="100">
We can use createLinearGradient() for this. Just converted 20% to 0.2 etc (sum has to be 1 or less);
data = {
green : 0.2,
yellow : 0.3,
red : 0.5
}
drawColorBar('canvas',data);
function drawColorBar(selector, data){
const canvas = document.querySelector(selector);
const ctx = canvas.getContext('2d');
const gradient = ctx.createLinearGradient(0,0,canvas.width,0);
let start = 0;
for(const color in data){
gradient.addColorStop(start, color);
start += data[color];
gradient.addColorStop(start, color);
}
ctx.fillStyle = gradient;
ctx.fillRect(0,0,canvas.width,canvas.height);
}
<canvas></canvas>

How to merge 2 images (one with shape) and make it transparant in an Canvas

I have 2 images of a pirate, one with the shape, and one with the pirate itself. I want to merge that 2 pictures together to create an image with transparant borders to use into my canvas. The only problem is i cant find a way to actually manage this.
The images itself are naturally not transparant at all, so i played with the Composite options in javascript, to try to make it transparant with the 'lighter' option, but i cant find a way to get rid of the white borders around the image.
This is a part of the code i currently have:
ctx.canvas.width = pirate_shape.width;
ctx.canvas.height = pirate_shape.height;
ctx.clearRect(0, 0, pirate_shape.width, pirate_shape.height);
// Draw the shape in reversed colors so i can draw the picture inside the shape.
ctx.drawImage(pirate_shape, 0, 0);
ctx.globalCompositeOperation='difference';
ctx.fillStyle='white';
ctx.fillRect(0, 0, pirate_shape.width, pirate_shape.height);
// Draw the pirate itself
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(pirate, 0, 0);
The complete code: https://jsfiddle.net/0kbj2Leg/
The result im trying to get:
https://imgur.com/uL0Pf4T
The 2 images i used:
(the pirate)
https://imgur.com/8R1qutU
(the shape)
https://imgur.com/qemOzU2
Do you guys know maybe an way to get that result?
Thanks!
You want to use compositing, not blending.
To do this, you need transparency. Your images don't contain any.
Since your border image is black and white, there is actually an easy way in modern browsers to convert the white to transparent pixels: svg filters.
Once done, we can use compositing to achieve our goal.
// just the assets loader
(async () => {
const [face, border, back] = await Promise.all(
[
'https://i.imgur.com/8R1qutU.png',
'https://imgur.com/qemOzU2.png',
'https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png'
].map(url => {
const img = new Image();
img.src = url;
return new Promise(res => {
img.onload = e => res(img);
});
})
);
const ctx = document.getElementById('canvas').getContext('2d');
// generate the border transparency based on luminance
// use the brightness to control how much red we keep
ctx.filter = 'brightness(180%) url(#trans)';
ctx.drawImage(border, 10, 10);
ctx.filter = 'none';
await wait(1000); // only for demo
// draw the face only where we already have the border drawn
ctx.globalCompositeOperation = 'source-in';
ctx.drawImage(face, 10, 10);
await wait(1000); // only for demo
// draw the background behind
ctx.globalCompositeOperation = 'destination-over';
ctx.drawImage(back, -100,0, 400,300);
// reset
ctx.globalCompositeOperation = 'source-over';
})();
// only for demo, so we can see the different steps
function wait(ms) { return new Promise(res => setTimeout(res, ms)) }
canvas {
background-color: ivory;
border: 1px solid lightgray;
}
<canvas id="canvas" width="200" height="200"></canvas>
<svg style="width:0px;height:0px;position:absolute;z-index:-1">
<defs>
<filter id="trans">
<feColorMatrix type="luminanceToAlpha"/>
</filter>
</defs>
</svg>
Now, it would obviously be a lot easier if you can prepare your assets correctly from the beginning:
// just the assets loader
(async () => {
const [face, border, back] = await Promise.all(
[
'https://i.imgur.com/8R1qutU.png',
'https://i.stack.imgur.com/778ZM.png', // already wth transparency
'https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png'
].map(url => {
const img = new Image();
img.src = url;
return new Promise(res => {
img.onload = e => res(img);
});
})
);
const ctx = document.getElementById('canvas').getContext('2d');
ctx.drawImage(border, 10, 10);
// draw the face only where we already have the border drawn
ctx.globalCompositeOperation = 'source-in';
ctx.drawImage(face, 10, 10);
// draw the background behind
ctx.globalCompositeOperation = 'destination-over';
ctx.drawImage(back, -100,0, 400,300);
// reset
ctx.globalCompositeOperation = 'source-over';
})();
canvas {
background-color: ivory;
border: 1px solid lightgray;
}
<canvas id="canvas" width="200" height="200"></canvas>
But you could probably even get rid of the border image entirely by drawing a shadow over a well prepared pirate image:
// just the assets loader
(async () => {
const [face,back] = await Promise.all(
[
'https://i.stack.imgur.com/1hzcD.png',
'https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png'
].map(url => {
const img = new Image();
img.src = url;
return new Promise(res => {
img.onload = e => res(img);
});
})
);
const ctx = document.getElementById('canvas').getContext('2d');
// draw the background
ctx.drawImage(back, -100,0, 400,300);
// draw the face with a shadow
ctx.shadowBlur = 15;
ctx.shadowColor = "red";
// little hack to make shadow more opaque
// first move your shadow away from what you wish to draw
ctx.shadowOffsetX = face.width + 10; // + 10 because we'll draw the last one at x:10
ctx.shadowOffsetY = face.height + 10;
// now draw your shape outside of the visible area
ctx.drawImage(face, -face.width, -face.height);
await wait(1000); // just for demo
ctx.drawImage(face, -face.width, -face.height);
// we now have to shadows overlapping but not our shape
await wait(1000); // just for demo
// reset the shadowOffset
ctx.shadowOffsetX = ctx.shadowOffsetY = 0;
// adn draw the last one
ctx.drawImage(face, 10, 10);
ctx.shadowBlur = 0;
ctx.shadowColor = 'transparent';
})();
// only for demo, so we can see the different steps
function wait(ms) { return new Promise(res => setTimeout(res, ms)) }
canvas {
background-color: ivory;
border: 1px solid lightgray;
}
<canvas id="canvas" width="200" height="200"></canvas>

canvas element resizing using window event

I am using typescript to draw canvas element. I would like to make my canvas element responsive to the screen size. So that I can match the canvas size to the parent div element.
I have tried to use this removing the size in canvas and providing it in .css. But this doesn't help because I have two fixed elements. I have found this solution which is 8 years old canvas resize.
How can I approach this problem ? The answer which I have posted distortes the image quality way too much. Is there any other approach ?
export class bar {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private width_canvas: number;
private height_canvas: number;
constructor(canvas: HTMLCanvasElement) {
this.canvas = < HTMLCanvasElement > canvas;
this.ctx = < CanvasRenderingContext2D > canvas.getContext('2d');
this.width_canvas = this.canvas.width;
this.height_canvas = this.canvas.height;
window.requestAnimationFrame(() => this.draw());
};
draw() {
let wid_bar: number = this.width_canvas - 400;
this.value.forEach((val, idx) => {
// draw bar background
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(200, (idx * (80)), wid_bar, 30);
this.ctx.fillStyle = yellow;
this.ctx.fill();
window.requestAnimationFrame(() => this.draw());
};
}
<div class="bar">
<canvas id="canvas" width="1200" height="200"> This does not work </canvas>
</div>
try (if you use CSS width only without redrawing then picture it will lose quality) instead input value read screen size
function change(inp) {
can.width = inp.value;
can.height = inp.value/4;
draw(can, [5,10,15,20])
}
function draw(canvas,value) {
let ctx = can.getContext('2d');
let wid_bar = canvas.width - 400;
value.forEach((val, idx) => {
// draw bar background
ctx.save();
ctx.beginPath();
ctx.rect(200, idx * 80, wid_bar, 30);
ctx.fillStyle = 'yellow';
ctx.fill();
});
}
change(audio);
canvas { background: black; }
<input id="audio" type="range" min="100" max="600" oninput="change(this)" value=150 /><br>
<canvas id="can"></canvas>

HTML Canvas: ctx.stroke restroke behaviour with transparent colors

I am working on a sketch tool with html canvas.
I am using a common algorithm for this, that uses the mousedown, mousemove, mouseup events.
mousedown
I beginPath(), and moveTo(// the mouse coordinates).
mousemove
I draw lineTo(// the mouse coordinates), and then stoke(// the line to render it)
mouseup
I do nothing, // closePath()
And I noticed that, calling the stroke method without first calling closePath or beginPath, will redraw or restroke all previous paths or lines, which makes them appear thicker than the define color.
without a transparent color its is barely noticeable, but the colors do appear thicker than they should be.
but with color with transparency|alpha e.g. rgba(). The most recent path or line respects the color's transparency, but all previous line due to the redraw, the transparent colored line overlap and that causes previous lines to get thicker in sequence or succession.
is there a way to avoid|prevent this behavior. thank in advance.
sample is below, try drawing really fast!
var cvs = document.querySelector("canvas");
cvs.width = cvs.parentElement.clientWidth;
var colorInput = document.querySelector("input");
var ctx = cvs.getContext("2d");
ctx.strokeStyle = "rgba(0, 0, 0, 0.4)"
ctx.lineWidth = 20;
onDraw(cvs, {
penDown: function(e) {
var x = e.pageX - this.offsetLeft;
var y = e.pageY - this.offsetTop;
ctx.strokeStyle = colorInput.value;
ctx.beginPath();
ctx.moveTo(x, y);
},
penMove: function(e) {
var x = e.pageX - this.offsetLeft;
var y = e.pageY - this.offsetTop;
ctx.lineTo(x, y);
ctx.stroke();
},
penUp: function() {
// ctx.closePath;
}
});
function onDraw(node, drawHandler, beginHandler, endHandler, outOfBoundHandler, sticky) {
var mouseDown = false, mouseOut = false;
if( typeof drawHandler === "object" ) {
var drawEvents = drawHandler;
drawHandler = get(drawEvents.penMove);
beginHandler = get(drawEvents.penDown);
endHandler = get(drawEvents.penUp);
outOfBoundHandler = get(drawEvents.penOff);
sticky = drawEvents.sticky;
}
function get(name) {
return typeof name === "string" ? drawEvents[ name ] : name;
}
node.addEventListener('mousedown', function(e) {
mouseDown = true;
beginHandler&&beginHandler.call(this, e);
});
node.addEventListener('mousemove', function(e) {
mouseDown&&drawHandler&&drawHandler.call(this, e);
});
node.addEventListener('mouseup', function(e) {
mouseDown = false;
endHandler&&endHandler.call(this, e);
});
node.addEventListener('mouseout', function(e) {
mouseDown&&outOfBoundHandler&&outOfBoundHandler.call(this, e);
if( !sticky ) {
mouseDown = false;
}
});
}
.wrapper { border: 1px solid #aaa }
<div class="wrapper">
<canvas border="1" width="600" hieght="400">Canvas is not supported</canvas>
<input type="text" value="rgba(0, 0, 0, 0.3)" placeholder="rgba(#, #, #, #)">
</div>
If no Path argument is passed to stroke and fill methods they will use the path currently being declared with the context's drawing methods.
const ctx = c.getContext('2d');
// starts Path declaration
ctx.moveTo(20, 20);
ctx.lineTo(30, 80);
ctx.stroke(); // first rendering
setTimeout(() => {
ctx.clearRect(0, 0, 300, 150); // even if we clear the canvas
ctx.lineTo(70, 20); // this will continue path declaration
setTimeout(() => {
ctx.stroke(); // and this will draw everything
}, 1000);
}, 1000);
<canvas id="c"></canvas>
The only ways to start a new path declaration (except for the first one) are to either reset the whole context (not good), or to use beginPath method.
const ctx = c.getContext('2d');
// starts Path declaration
ctx.moveTo(20, 20);
ctx.lineTo(30, 80);
ctx.stroke(); // first rendering
setTimeout(() => {
ctx.clearRect(0, 0, 300, 150);
ctx.beginPath(); // start a new Path declaration
ctx.moveTo(30, 80); // we need to move to the previous coords
ctx.lineTo(70, 20); // this will be alone
ctx.stroke(); // and this will draw only the new path
}, 1000);
<canvas id="c"></canvas>
About closePath, it's just a lineTo(last_point_in_current_path_declaration), and doesn't ends a path declaration in no way.
So for your problem, there are two strategies you can adopt :
keep only the last coordinates, and at every mousemove,
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(nextX, nextY);
keep all your coordinates in an array and redraw everything every time
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
coords.forEach(pt => ctx.lineTo(pt.x, pt.y));
ctx.stroke();
Personally, I prefer the second one, which allows some undo - redo, and to e.g change your pen's style.

fabric.js: How to make a unique, transparent shape with stroke?

I want to free-draw shapes with fabric.js. The outline is, say, 20px (such that the user sees it clearly).
After the user has drawn it, the shape should been filled with the same color as the outline.
The whole thing should be semi-transparent. Unfortunately, this causes the overlap between outline and fill to be less transparent and draws a strange "inner outline" to the shape.
Is there a way to make shape uniquely semi-transparent?
Maybe a trick would be: after the user has drawn the shape, "widen" the shape by half of outline thickness and set outline thickness to 1. Would that be possible?
See this https://jsfiddle.net/4ypdwe9o/ or below for an example.
var canvas = new fabric.Canvas('c', {
isDrawingMode: true,
});
canvas.freeDrawingBrush.width = 10;
canvas.freeDrawingBrush.color = 'rgb(255, 0, 0)';
canvas.on('mouse:up', function() {
canvas.getObjects().forEach(o => {
o.fill = 'rgb(255, 0, 0)';
o.opacity = 0.5;
});
canvas.renderAll();
})
canvas {
border: 1px solid #ccc;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/1.6.3/fabric.js"></script>
<canvas id="c" width="600" height="600"></canvas>
It's a bit tricky but can be solved using a temporary canvas. So you first render the path using a solid color fill on the temporary canvas, then copy it to the main canvas like this:
//create temporary canvas
var tmpCanvas = document.createElement("canvas");
tmpCanvas.width = canvas.width;
tmpCanvas.height = canvas.height;
var tmpCtx = tmpCanvas.getContext("2d");
//remember the original render function
var pathRender = fabric.Path.prototype.render;
//override the Path render function
fabric.util.object.extend(fabric.Path.prototype, {
render: function(ctx, noTransform) {
var opacity = this.opacity;
//render the path with solid fill on the temp canvas
this.opacity = 1;
tmpCtx.clearRect(0, 0, tmpCanvas.width, tmpCanvas.height);
pathRender.apply(this, [tmpCtx]);
this.opacity = opacity;
//copy the path from the temp canvas
ctx.globalAlpha = opacity;
ctx.drawImage(tmpCanvas, 0, 0);
}
});
See plunker here: https://plnkr.co/edit/r1Gs2wIoWSB0nSS32SrL?p=preview
In fabric.js 1.7.3, they have another implementation. When I use
fabricCanvasObject.getObjects('path').slice(-1)[0].setFill("red");
fabricCanvasObject.getObjects('path').slice(-1)[0].setOpacity(0.5);
instead of
fabricCanvasObject.getObjects('path').slice(-1)[0].fill = "red";
fabricCanvasObject.getObjects('path').slice(-1)[0].opacity = 0.5;
The boundary is painted correctly, without overlap. So, the temporary canvas from Janusz's answer is not needed anymore. For my former fabric.js 1.5.0, the answer from Janusz solved the problem.
Jetic,
You are almost finished your logic. Instead of using "opacity" use rgba:
canvas.getObjects().forEach(o => {
o.fill = 'rgba(255, 0, 0, 0.5)';
o.stroke = 'rgba(255, 0, 0, 0)';
// o.opacity = 0.5;
});
canvas.renderAll();

Categories

Resources