├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── demo.css ├── demo.js ├── dist ├── StarFieldCanvas.js └── StarFieldCanvas.js.map ├── gulpfile.js ├── index.html ├── package-lock.json ├── package.json ├── src ├── Burst.ts ├── Star.ts ├── index.ts └── starColor.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Diagnostic reports (https://nodejs.org/api/report.html) 7 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 8 | 9 | # Dependency directories 10 | node_modules/ 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # star-field-canvas 2 | Configurable fly-through star field effect for HTML canvas. 3 | 4 | https://tdous.github.io/star-field-canvas/ 5 | 6 | ## Installing 7 | ``` 8 | npm install --save tdous/star-field-canvas 9 | ``` 10 | 11 | ## Usage 12 | 13 | ### Instantiating 14 | The main class is exported at the moment as a traditional global library, not an ES6 module. This is done via the webpack expose-loader, hence it's encapsulated within a 'library' to minimize pollution, which means instantiating with... 15 | 16 | ``` 17 | var sf = new StarFieldCanvas.StarField('my-canvas-element-id'); 18 | 19 | sf.start(); 20 | ``` 21 | 22 | ...rather than simply "new Starfield(...)". 23 | 24 | ### Options 25 | 26 | The ```canvas``` id is required. Otherwise all options are... optional. These are the defaults: 27 | 28 | ``` 29 | var sf = new StarFieldCanvas.StarField('my-canvas-element-id', { 30 | followContext: [uses 'this.canvas' within the class], 31 | followMouse: false, 32 | color: { r: 255, g: 255, b: 255 }, 33 | glow: false, 34 | minV: 2, 35 | maxV: 5, 36 | numStars: 400, 37 | trails: false 38 | }); 39 | 40 | sf.start(); 41 | ``` 42 | 43 | The ```followContext``` option is the element or object to attach the mousemove listener to, if you enable ```followMouse``` but need it to respond to something other than the canvas, perhaps if the canvas is behind another fullscreen element. eg. you can use the global window as the context: 44 | 45 | ``` 46 | var sf = new StarFieldCanvas.StarField('my-canvas-element-id', { 47 | followContext: window, 48 | ... 49 | }); 50 | ``` 51 | 52 | ### Stop is you must 53 | 54 | ``` 55 | sf.stop(); 56 | ``` 57 | 58 | ### Style the ```canvas``` yourself 59 | 60 | In case you want to use it for a flying star field effect but with alternate colors, ie. not white stars on black space background, this script does not fill the canvas background at all. It *only* draws the stars. You should style the canvas with CSS. There is no need to use height or width attributes on the ```canvas``` element CSS computed values be applied to the canvas in the script. 61 | 62 | See the demo JS file for an example, running at https://tdous.github.io/star-field-canvas/. 63 | -------------------------------------------------------------------------------- /demo.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | html { background-color: #222; } 8 | 9 | html, 10 | body { 11 | height: 100vh; 12 | width: 100vw; 13 | } 14 | 15 | #space { 16 | background-color: #000; 17 | height: 100vh; 18 | position: absolute; 19 | width: 100vw; 20 | } 21 | 22 | .controls { 23 | background-color: rgba(20, 20, 20, 0.4); 24 | bottom: 10px; 25 | color: #FFF; 26 | height: 200px; 27 | left: 10px; 28 | padding: 20px; 29 | position: absolute; 30 | width: 200px; 31 | } 32 | 33 | 34 | .controls ul { 35 | display: flex; 36 | flex: 1; 37 | flex-direction: column; 38 | height: 100%; 39 | justify-content: space-evenly; 40 | } 41 | 42 | .controls li { 43 | list-style: none; 44 | align-items: center; 45 | display: flex; 46 | flex-direction: row; 47 | justify-content: space-between; 48 | width: 100%; 49 | } 50 | 51 | .controls label { 52 | flex: 1; 53 | font-family: "Open Sans", "Roboto", sans-serif; 54 | font-size: 0.7em; 55 | color: #AAA; 56 | } 57 | .controls input { 58 | background-color: rgba(255, 255, 255, 0.8); 59 | border: none; 60 | border-radius: 2px; 61 | flex: 1; 62 | margin-left: 10px; 63 | padding: 5px 5px; 64 | width: 1px; 65 | } -------------------------------------------------------------------------------- /demo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Example of usage with some extra stuff to test the config on the fly 3 | */ 4 | 5 | // Canvas element to use - style/position this with CSS 6 | var canvasId = 'space'; // required 7 | 8 | // If you enable 'followMouse' and need mousemove on different element to canvas 9 | // var followContext = window; // optional, default 'this.canvas' in class 10 | 11 | // Illusion of changing view direction due to mouse pos 12 | var followMouse = false; // optional, default false 13 | 14 | // Star color 15 | var color = { r: 255, g: 255, b: 255 }; // optional, default 255, 255, 255 16 | 17 | // Subtle glow with canvas shadow - performance drain 18 | var glow = false; // optional, default false 19 | 20 | // Min velocity range 21 | var minV = 2; // optional, default 2 22 | 23 | // Max velocity range 24 | var maxV = 5; // optional, default 5 25 | 26 | // Perforance starts to degrade beyond around 1500 stars, system-dependent 27 | var numStars = 400; // optional, default 400 28 | 29 | // Subtle trail lines which help the illusion of speed 30 | var trails = true; // optional, default false 31 | 32 | // Setup the Starfield 33 | // var starfield = new Starfield.Starfield(canvasId); // Run with defaults 34 | var starfield = new StarFieldCanvas.StarField(canvasId, { 35 | // followContext: followContext 36 | followMouse: followMouse, 37 | color: color, 38 | glow: glow, 39 | minV: minV, 40 | maxV: maxV, 41 | numStars: numStars, 42 | trails: trails 43 | }); 44 | 45 | // Make stars happen 46 | starfield.start(); 47 | 48 | 49 | // == Demo config stuff below - not needed for usage == 50 | 51 | // Get config edit fields and set current defaults 52 | var txtNumStars = document.getElementById('ctrlNumStars'); 53 | txtNumStars.value = numStars.toString(); 54 | var txtMaxV = document.getElementById('ctrlMaxV'); 55 | txtMaxV.value = maxV.toString(); 56 | var txtMinV = document.getElementById('ctrlMinV'); 57 | txtMinV.value = minV.toString(); 58 | var chkMouse = document.getElementById('ctrlMouse'); 59 | chkMouse.checked = followMouse; 60 | 61 | // Some key events for the config test form 62 | txtNumStars.onkeyup = function () { 63 | starfield.setNumStars(parseInt(txtNumStars.value)); 64 | }; 65 | txtMaxV.onkeyup = function () { 66 | starfield.setMaxV(parseInt(txtMaxV.value)); 67 | }; 68 | txtMinV.onkeyup = function () { 69 | starfield.setMinV(parseInt(txtMinV.value)); 70 | }; 71 | chkMouse.onchange = function () { 72 | starfield.setFollowMouse(chkMouse.checked); 73 | }; -------------------------------------------------------------------------------- /dist/StarFieldCanvas.js: -------------------------------------------------------------------------------- 1 | var StarFieldCanvas=function(t){var s={};function i(e){if(s[e])return s[e].exports;var a=s[e]={i:e,l:!1,exports:{}};return t[e].call(a.exports,a,a.exports,i),a.l=!0,a.exports}return i.m=t,i.c=s,i.d=function(t,s,e){i.o(t,s)||Object.defineProperty(t,s,{enumerable:!0,get:e})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,s){if(1&s&&(t=i(t)),8&s)return t;if(4&s&&"object"==typeof t&&t&&t.__esModule)return t;var e=Object.create(null);if(i.r(e),Object.defineProperty(e,"default",{enumerable:!0,value:t}),2&s&&"string"!=typeof t)for(var a in t)i.d(e,a,function(s){return t[s]}.bind(null,a));return e},i.n=function(t){var s=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(s,"a",s),s},i.o=function(t,s){return Object.prototype.hasOwnProperty.call(t,s)},i.p="",i(i.s=0)}([function(t,s,i){"use strict";i.r(s);class e{constructor(){this.animate=!1,this.frameReqId=0,this.frameTasks=[],this.lastFrameTaskId=0,this.loop=(t=0)=>{const s=this.frameTasks.length;for(let i=0;i{this.frameTasks.push({id:this.lastFrameTaskId,fn:t}),s.push(this.lastFrameTaskId),this.lastFrameTaskId++}),s)}deleteTask(t){this.frameTasks=this.frameTasks.filter(s=>s.id!==t)}start(t){this.animate||(this.animate=!0,this.loop())}stop(){cancelAnimationFrame(this.frameReqId),this.animate=!1}}const a=(t,s,i,e,a)=>(t-s)*(a-e)/(i-s)+e;var h={r:255,b:255,g:255},n=function(){function t(t){this.x=0,this.y=0,this.z=0,this.v=0,this.radius=0,this.lastX=0,this.lastY=0,this.splashLimitX=[0,0],this.splashLimitY=[0,0];var s=t.ctx,i=t.W,e=t.H,a=t.hW,n=t.hH,o=t.minV,r=t.maxV,l=t.color,f=t.glow,c=t.trails,u=t.addTasks;this.ctx=s,this.W=i,this.H=e,this.hW=a,this.hH=n,this.minV=o,this.maxV=r,this.glow=f,this.trails=c,this.color=l||h,this.splashLimitX=[-a,a],this.splashLimitY=[-n,n],this.addTasks=u,this.reset(!0)}return t.prototype.getInitialZ=function(){return 2*(this.W>this.H?this.H:this.W)},t.prototype.draw=function(t,s){this.z-=this.v,this.z<=0&&this.reset();var i=this.W*(this.x/this.z)-t,e=this.H*(this.y/this.z)-s,h=this.getInitialZ(),n=(1-a(this.z,0,h,0,1))*this.radius,o=Math.round(10-a(this.z,0,h,0,10))/10,r=o/4;this.trails&&this.lastX!==this.x&&(this.ctx.lineWidth=n,this.ctx.strokeStyle="rgba("+this.color.r+", "+this.color.g+", "+this.color.b+", "+r+")",this.ctx.beginPath(),this.ctx.moveTo(i,e),this.ctx.lineTo(this.lastX,this.lastY),this.ctx.stroke()),this.glow&&(this.ctx.save(),this.ctx.shadowBlur=5,this.ctx.shadowColor="#FFF"),this.ctx.fillStyle="rgb("+this.color.r+", "+this.color.g+", "+this.color.b+", "+o+")",this.ctx.beginPath(),this.ctx.arc(i,e,n,0,2*Math.PI),this.ctx.fill(),this.glow&&this.ctx.restore(),this.lastX=i,this.lastY=e},t.prototype.reset=function(t){void 0===t&&(t=!1),this.x=Math.random()*this.W-this.hW,this.y=Math.random()*this.H-this.hH,this.v=Math.random()*(this.maxV-this.minV)+this.minV,this.radius=Number((2*Math.random()+1).toPrecision(3)),this.lastX=this.x,this.lastY=this.y,this.z=t?Math.random()*this.getInitialZ():this.getInitialZ()},t}();i.d(s,"StarField",(function(){return o}));var o=function(){function t(t,s){var i=this;if(void 0===s&&(s={}),this.defaultMaxV=5,this.defaultMinV=2,this.defaultNumStars=400,this.initialized=!1,this.canvasW=0,this.canvasH=0,this.canvasHalfW=0,this.canvasHalfH=0,this.offsetX=0,this.offsetY=0,this.offsetTX=0,this.offsetTY=0,this.stars=[],this.resizeTimeout=0,!t)throw'First argument "id" is required';this.color=s.color||h,this.glow=s.glow||!1,this.minV=s.minV||this.defaultMinV,this.maxV=s.maxV||this.defaultMaxV,this.numStars=this.defaultNumStars,this.trails=s.trails||!1,this.canvas=document.getElementById(t),this.ctx=this.canvas.getContext("2d");var a=this.canvas.getBoundingClientRect();this.canvasRectLeft=a.left,this.canvasRectTop=a.top,this.followContext=s.followContext||this.canvas,this.handleMouseMove=this.handleMouseMove.bind(this),this.engine=new e,this.engine.addTask(this.draw.bind(this)),window.addEventListener("blur",(function(){i.stop()})),window.addEventListener("focus",(function(){i.start()})),window.addEventListener("resize",(function(){clearTimeout(i.resizeTimeout),i.stop(),i.resizeTimeout=setTimeout((function(){i.reset(),i.start()}),500)})),this.numStars=s.numStars?Math.abs(s.numStars):this.defaultNumStars,this.setupCanvas(),this.generateStars(),this.initialized=!0,s.followMouse&&this.setFollowMouse(!0)}return t.prototype.generateStars=function(){for(var t=0;t {\r\n const numTasks = this.frameTasks.length;\r\n for (let i = 0; i < numTasks; i++) {\r\n this.frameTasks[i].fn(ts);\r\n }\r\n this.frameReqId = requestAnimationFrame(this.loop);\r\n };\r\n this.addTasks = this.addTasks.bind(this);\r\n }\r\n addTask(task) {\r\n return this.addTasks([task])[0];\r\n }\r\n addTasks(tasks) {\r\n const createdIds = [];\r\n if (tasks.length == 0) {\r\n return createdIds;\r\n }\r\n tasks.forEach(task => {\r\n this.frameTasks.push({ id: this.lastFrameTaskId, fn: task });\r\n createdIds.push(this.lastFrameTaskId);\r\n this.lastFrameTaskId++;\r\n });\r\n return createdIds;\r\n }\r\n deleteTask(taskId) {\r\n this.frameTasks = this.frameTasks.filter(t => t.id !== taskId);\r\n }\r\n start(debugInterval) {\r\n if (!this.animate) {\r\n this.animate = true;\r\n this.loop();\r\n }\r\n }\r\n stop() {\r\n cancelAnimationFrame(this.frameReqId);\r\n this.animate = false;\r\n }\r\n}\r\n//# sourceMappingURL=index.js.map","// Scale a number, mapping between two number ranges\r\nexport const mapNumberToRange = (input, inputRangeMin, inputRangeMax, outputRangeMin, outputRangeMax) => {\r\n return ((input - inputRangeMin) * (outputRangeMax - outputRangeMin) /\r\n (inputRangeMax - inputRangeMin) +\r\n outputRangeMin);\r\n};\r\n//# sourceMappingURL=index.js.map","export var defaultColor = {\r\n r: 255,\r\n b: 255,\r\n g: 255\r\n};\r\n","import { mapNumberToRange } from 'map-number-to-range';\r\nimport { defaultColor } from './starColor';\r\nvar Star = /** @class */ (function () {\r\n function Star(opts) {\r\n this.x = 0;\r\n this.y = 0;\r\n this.z = 0;\r\n this.v = 0;\r\n this.radius = 0;\r\n this.lastX = 0;\r\n this.lastY = 0;\r\n this.splashLimitX = [0, 0];\r\n this.splashLimitY = [0, 0];\r\n var ctx = opts.ctx, W = opts.W, H = opts.H, hW = opts.hW, hH = opts.hH, minV = opts.minV, maxV = opts.maxV, color = opts.color, glow = opts.glow, trails = opts.trails, addTasks = opts.addTasks;\r\n this.ctx = ctx;\r\n this.W = W;\r\n this.H = H;\r\n this.hW = hW;\r\n this.hH = hH;\r\n this.minV = minV;\r\n this.maxV = maxV;\r\n this.glow = glow;\r\n this.trails = trails;\r\n this.color = color ? color : defaultColor;\r\n this.splashLimitX = [-hW, hW];\r\n this.splashLimitY = [-hH, hH];\r\n this.addTasks = addTasks;\r\n this.reset(true);\r\n }\r\n // Get the star's initial Z depth\r\n Star.prototype.getInitialZ = function () {\r\n return (this.W > this.H ? this.H : this.W) * 2;\r\n };\r\n // Calculate the star's current position star at the current\r\n Star.prototype.draw = function (offsetX, offsetY) {\r\n this.z -= this.v;\r\n if (this.z <= 0) {\r\n // Start of attempting to add bursts on \"collision\" with the viewport \r\n // if (\r\n // this.lastX > this.splashLimitX[0] &&\r\n // this.lastX < this.splashLimitX[1] &&\r\n // this.lastY > this.splashLimitY[0] &&\r\n // this.lastY < this.splashLimitY[1]\r\n // ) {\r\n // console.log(this.lastX, this.splashLimitX, this.lastY, this.splashLimitY);\r\n // const ex = new Explosion({ ctx: this.ctx, x: this.x, y: this.y });\r\n // this.addTasks([ex.draw.bind(ex)]);\r\n // }\r\n this.reset();\r\n }\r\n // Update x and y - 0.8 is an arbitrary fraction of the\r\n var newX = this.W * (this.x / this.z) - offsetX;\r\n var newY = this.H * (this.y / this.z) - offsetY;\r\n // Get max Z and calc new radius/opacity based on star's position in Z range\r\n var maxZ = this.getInitialZ();\r\n // Calculate a new radius based on Z\r\n var newRadius = (1 - mapNumberToRange(this.z, 0, maxZ, 0, 1)) * this.radius;\r\n // Calculate a new opacity based on Z\r\n var opacity = Math.round(10 - mapNumberToRange(this.z, 0, maxZ, 0, 10)) / 10;\r\n var trailOpacity = opacity / 4;\r\n // Draw star trail\r\n if (this.trails && this.lastX !== this.x) {\r\n this.ctx.lineWidth = newRadius;\r\n this.ctx.strokeStyle = \"rgba(\" + this.color.r + \", \" + this.color.g + \", \" + this.color.b + \", \" + trailOpacity + \")\";\r\n this.ctx.beginPath();\r\n this.ctx.moveTo(newX, newY);\r\n this.ctx.lineTo(this.lastX, this.lastY);\r\n this.ctx.stroke();\r\n }\r\n // Save drawing settings to restore after applying the glow to stars only\r\n if (this.glow) {\r\n this.ctx.save();\r\n this.ctx.shadowBlur = 5;\r\n this.ctx.shadowColor = '#FFF';\r\n }\r\n // Draw the star\r\n this.ctx.fillStyle = \"rgb(\" + this.color.r + \", \" + this.color.g + \", \" + this.color.b + \", \" + opacity + \")\";\r\n this.ctx.beginPath();\r\n this.ctx.arc(newX, newY, newRadius, 0, Math.PI * 2);\r\n this.ctx.fill();\r\n // Undo glow settings\r\n if (this.glow) {\r\n this.ctx.restore();\r\n }\r\n // Update last x/y\r\n this.lastX = newX;\r\n this.lastY = newY;\r\n };\r\n // (Re)set the star, either initially (init) or when reaching the depth limit\r\n Star.prototype.reset = function (init) {\r\n if (init === void 0) { init = false; }\r\n // Define a new random position within the canvas, velocity, and radius\r\n this.x = Math.random() * this.W - this.hW;\r\n this.y = Math.random() * this.H - this.hH;\r\n this.v = Math.random() * (this.maxV - this.minV) + this.minV;\r\n this.radius = Number((Math.random() * 2 + 1).toPrecision(3));\r\n // Clear last x/y so we don't draw a trail from end to new reset location\r\n this.lastX = this.x;\r\n this.lastY = this.y;\r\n // If not init (ie. not first run), send to furthest Z, otherwise randomize\r\n this.z = !init ? this.getInitialZ() : Math.random() * this.getInitialZ();\r\n };\r\n return Star;\r\n}());\r\nexport { Star };\r\n","import { AnimLoopEngine } from 'anim-loop-engine';\r\nimport { Star } from './Star';\r\nimport { defaultColor } from './starColor';\r\n// StarField factory\r\nvar StarField = /** @class */ (function () {\r\n function StarField(canvasId, opts) {\r\n var _this = this;\r\n if (opts === void 0) { opts = {}; }\r\n this.defaultMaxV = 5;\r\n this.defaultMinV = 2;\r\n this.defaultNumStars = 400;\r\n this.initialized = false;\r\n this.canvasW = 0;\r\n this.canvasH = 0;\r\n this.canvasHalfW = 0;\r\n this.canvasHalfH = 0;\r\n this.offsetX = 0;\r\n this.offsetY = 0;\r\n this.offsetTX = 0;\r\n this.offsetTY = 0;\r\n this.stars = [];\r\n this.resizeTimeout = 0;\r\n if (!canvasId) {\r\n throw 'First argument \"id\" is required';\r\n return;\r\n }\r\n this.color = opts.color || defaultColor;\r\n this.glow = opts.glow || false;\r\n this.minV = opts.minV || this.defaultMinV;\r\n this.maxV = opts.maxV || this.defaultMaxV;\r\n this.numStars = this.defaultNumStars;\r\n this.trails = opts.trails || false;\r\n this.canvas = document.getElementById(canvasId);\r\n this.ctx = this.canvas.getContext('2d');\r\n var rect = this.canvas.getBoundingClientRect();\r\n this.canvasRectLeft = rect.left;\r\n this.canvasRectTop = rect.top;\r\n // Assign follow context now that this.canvas was assigned\r\n this.followContext = opts.followContext || this.canvas;\r\n this.handleMouseMove = this.handleMouseMove.bind(this);\r\n // Set up animation engine\r\n this.engine = new AnimLoopEngine();\r\n this.engine.addTask(this.draw.bind(this));\r\n // Set up window events\r\n // Window blur/focus\r\n window.addEventListener('blur', function () {\r\n _this.stop();\r\n });\r\n window.addEventListener('focus', function () {\r\n _this.start();\r\n });\r\n // Window event - on resize to reinitialize canvas, all stars and animation\r\n window.addEventListener('resize', function () {\r\n clearTimeout(_this.resizeTimeout);\r\n _this.stop();\r\n _this.resizeTimeout = setTimeout(function () {\r\n _this.reset();\r\n _this.start();\r\n }, 500);\r\n });\r\n // Did config set a number of stars?\r\n this.numStars = opts.numStars\r\n ? Math.abs(opts.numStars)\r\n : this.defaultNumStars;\r\n // Setup the canvas\r\n this.setupCanvas();\r\n // Gen new stars\r\n this.generateStars();\r\n this.initialized = true;\r\n // Did config enable mouse following?\r\n if (opts.followMouse) {\r\n this.setFollowMouse(true);\r\n }\r\n }\r\n // Generate n new stars\r\n StarField.prototype.generateStars = function () {\r\n for (var i = 0; i < this.numStars; i++) {\r\n this.stars.push(new Star({\r\n ctx: this.ctx,\r\n W: this.canvasW,\r\n H: this.canvasH,\r\n hW: this.canvasHalfW,\r\n hH: this.canvasHalfH,\r\n minV: this.minV,\r\n maxV: this.maxV,\r\n color: this.color,\r\n glow: this.glow,\r\n trails: this.trails,\r\n addTasks: this.engine.addTasks\r\n }));\r\n }\r\n };\r\n // Apply canvas container size to canvas and translate origin to center\r\n StarField.prototype.setupCanvas = function () {\r\n var canvasStyle = window.getComputedStyle(this.canvas);\r\n this.canvas.setAttribute('height', canvasStyle.height);\r\n this.canvas.setAttribute('width', canvasStyle.width);\r\n // canvasH/W/canvasHalfH/W used here and set to use elsewhere\r\n this.canvasH = this.canvas.height;\r\n this.canvasW = this.canvas.width;\r\n this.canvasHalfH = this.canvasH / 2;\r\n this.canvasHalfW = this.canvasW / 2;\r\n this.ctx.translate(this.canvasHalfW, this.canvasHalfH);\r\n };\r\n // Draw the stars in this frame\r\n StarField.prototype.draw = function () {\r\n // Adjust offsets closer to target offset\r\n if (this.offsetX !== this.offsetTX) {\r\n this.offsetX += (this.offsetTX - this.offsetX) * 0.02;\r\n this.offsetY += (this.offsetTY - this.offsetY) * 0.02;\r\n }\r\n // Clear the canvas ready for this frame\r\n this.ctx.clearRect(-this.canvasHalfW, -this.canvasHalfH, this.canvasW, this.canvasH);\r\n for (var i in this.stars) {\r\n this.stars[i].draw(this.offsetX, this.offsetY);\r\n }\r\n };\r\n // Follow mouse (used in event listener definition)\r\n StarField.prototype.handleMouseMove = function (e) {\r\n if (this.initialized) {\r\n this.offsetTX = e.clientX - this.canvasRectLeft - this.canvasHalfW;\r\n this.offsetTY = e.clientY - this.canvasRectTop - this.canvasHalfH;\r\n }\r\n };\r\n StarField.prototype.resetMouseOffset = function () {\r\n this.offsetTX = 0;\r\n this.offsetTY = 0;\r\n };\r\n // Start/stop the StarField\r\n StarField.prototype.start = function () {\r\n this.engine.start();\r\n };\r\n StarField.prototype.stop = function () {\r\n this.engine.stop();\r\n };\r\n StarField.prototype.reset = function () {\r\n // Clear stars\r\n this.stars = [];\r\n // Reset canvas\r\n this.setupCanvas();\r\n // Gen new stars\r\n this.generateStars();\r\n };\r\n // \"Hot\"-updateable config values\r\n StarField.prototype.setMaxV = function (val) {\r\n this.maxV = val ? Math.abs(val) : this.defaultMaxV;\r\n this.reset();\r\n };\r\n StarField.prototype.setMinV = function (val) {\r\n this.minV = val ? Math.abs(val) : this.defaultMinV;\r\n this.reset();\r\n };\r\n StarField.prototype.setNumStars = function (val) {\r\n this.numStars = val ? Math.abs(val) : this.defaultNumStars;\r\n this.reset();\r\n };\r\n StarField.prototype.setFollowMouse = function (val) {\r\n if (val) {\r\n this.followContext.addEventListener('mousemove', this.handleMouseMove);\r\n }\r\n else {\r\n this.followContext.removeEventListener('mousemove', this.handleMouseMove);\r\n this.resetMouseOffset();\r\n }\r\n };\r\n return StarField;\r\n}());\r\nexport { StarField };\r\n"],"sourceRoot":""} -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { dest, parallel, series, src, task, watch } = require('gulp'); 4 | // const rename = require('gulp-rename'); 5 | // const sass = require('gulp-sass'); 6 | // const babel = require('gulp-babel'); 7 | const webpack = require('webpack-stream'); 8 | 9 | const mode = process.env.NODE_ENV || 'development'; 10 | 11 | const JS_OUTPUT_FILE = 'StarFieldCanvas.js'; 12 | 13 | // >>> TS to JS 14 | task('ts', async () => 15 | src(path.resolve(__dirname, 'src', 'index.ts')) 16 | .pipe( 17 | webpack({ 18 | devtool: 'source-map', 19 | output: { 20 | library: 'StarFieldCanvas', 21 | filename: JS_OUTPUT_FILE 22 | }, 23 | mode, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.tsx?$/, 28 | use: { 29 | loader: 'ts-loader' 30 | } 31 | } 32 | ] 33 | }, 34 | resolve: { 35 | extensions: ['.ts', '.tsx', '.js'] 36 | } 37 | }).on('error', err => console.log('WEBPACK ERROR:', err)) 38 | ) 39 | // .pipe( 40 | // babel({ 41 | // presets: ['@babel/env'] 42 | // }) 43 | // ) 44 | .pipe(dest(path.resolve(__dirname, 'dist'))) 45 | ); 46 | // Watch TS 47 | task('ts:w', () => { 48 | watch(path.resolve(__dirname, 'src'), series('ts')); 49 | }); 50 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | StarField Demo 9 | 10 | 11 | 12 | 13 |
14 |
    15 |
  • 16 | 17 | 18 |
  • 19 |
  • 20 | 21 | 22 |
  • 23 |
  • 24 | 25 | 26 |
  • 27 |
  • 28 | 29 | 30 |
  • 31 |
32 |
33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "star-field-canvas", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "npx gulp ts", 8 | "watch": "npx gulp ts:w" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "gulp": "^4.0.2", 14 | "ts-loader": "^6.2.1", 15 | "typescript": "^3.7.2", 16 | "webpack-stream": "^5.2.1" 17 | }, 18 | "dependencies": { 19 | "anim-loop-engine": "github:tdous/anim-loop-engine", 20 | "map-number-to-range": "github:tdous/map-number-to-range" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Burst.ts: -------------------------------------------------------------------------------- 1 | // type ExplosionOpts = { 2 | // ctx: any; 3 | // x: number; 4 | // y: number; 5 | // }; 6 | 7 | // export class Burst 8 | // { 9 | // ctx: any; 10 | // x: number = 0; 11 | // y: number = 0; 12 | 13 | // constructor(opts: ExplosionOpts) { 14 | // const { ctx, x, y } = opts; 15 | 16 | // this.ctx = ctx; 17 | // this.x = x; 18 | // this.y = y; 19 | 20 | // console.log('EX CREATED AT', x, y); 21 | 22 | // this.ctx.fillStyle = '#F00'; 23 | // } 24 | 25 | // draw() { 26 | // if (this.ctx) { 27 | // this.ctx.beginPath(); 28 | // this.ctx.arc(this.x, this.y, 5, 0, Math.PI * 2); 29 | // this.ctx.fill(); 30 | // } 31 | // } 32 | 33 | // update() { 34 | // // this.z -= this.v; 35 | // // if (this.z <= 100) { 36 | // // this.ctx.fillStyle = 'F00;'; 37 | // // this.ctx.beginPath(); 38 | // // this.ctx.arc(this.x, this.y, 100, 0, Math.PI * 2); 39 | // // this.ctx.fill(); 40 | // // this.reset(); 41 | // // } 42 | // } 43 | // } 44 | -------------------------------------------------------------------------------- /src/Star.ts: -------------------------------------------------------------------------------- 1 | import { mapNumberToRange } from 'map-number-to-range'; 2 | 3 | import { defaultColor, StarColorObj } from './starColor'; 4 | 5 | type StarOpts = { 6 | ctx: any; 7 | W: number; 8 | H: number; 9 | hW: number; 10 | hH: number; 11 | minV: number; 12 | maxV: number; 13 | color?: StarColorObj; 14 | glow: boolean; 15 | trails: boolean; 16 | addTasks?: Function; 17 | }; 18 | 19 | export class Star { 20 | private x: number = 0; 21 | private y: number = 0; 22 | private z: number = 0; 23 | private v: number = 0; 24 | private radius: number = 0; 25 | 26 | private lastX: number = 0; 27 | private lastY: number = 0; 28 | 29 | private splashLimitX: number[] = [0, 0]; 30 | private splashLimitY: number[] = [0, 0]; 31 | 32 | ctx: any; 33 | W: number; 34 | H: number; 35 | hW: number; 36 | hH: number; 37 | minV: number; 38 | maxV: number; 39 | color: StarColorObj; 40 | glow: boolean; 41 | trails: boolean; 42 | 43 | addTasks: Function; 44 | 45 | constructor(opts: StarOpts) { 46 | const { 47 | ctx, 48 | W, 49 | H, 50 | hW, 51 | hH, 52 | minV, 53 | maxV, 54 | color, 55 | glow, 56 | trails, 57 | addTasks 58 | } = opts; 59 | 60 | this.ctx = ctx; 61 | this.W = W; 62 | this.H = H; 63 | this.hW = hW; 64 | this.hH = hH; 65 | this.minV = minV; 66 | this.maxV = maxV; 67 | this.glow = glow; 68 | this.trails = trails; 69 | this.color = color ? color : defaultColor; 70 | 71 | this.splashLimitX = [-hW, hW]; 72 | this.splashLimitY = [-hH, hH]; 73 | 74 | this.addTasks = addTasks!; 75 | 76 | this.reset(true); 77 | } 78 | 79 | // Get the star's initial Z depth 80 | private getInitialZ() { 81 | return (this.W > this.H ? this.H : this.W) * 2; 82 | } 83 | 84 | // Calculate the star's current position star at the current 85 | draw(offsetX: number, offsetY: number) { 86 | this.z -= this.v; 87 | if (this.z <= 0) { 88 | // Start of attempting to add bursts on "collision" with the viewport 89 | // if ( 90 | // this.lastX > this.splashLimitX[0] && 91 | // this.lastX < this.splashLimitX[1] && 92 | // this.lastY > this.splashLimitY[0] && 93 | // this.lastY < this.splashLimitY[1] 94 | // ) { 95 | // console.log(this.lastX, this.splashLimitX, this.lastY, this.splashLimitY); 96 | // const ex = new Explosion({ ctx: this.ctx, x: this.x, y: this.y }); 97 | // this.addTasks([ex.draw.bind(ex)]); 98 | // } 99 | 100 | this.reset(); 101 | } 102 | 103 | // Update x and y - 0.8 is an arbitrary fraction of the 104 | let newX = this.W * (this.x / this.z) - offsetX; 105 | let newY = this.H * (this.y / this.z) - offsetY; 106 | 107 | // Get max Z and calc new radius/opacity based on star's position in Z range 108 | const maxZ = this.getInitialZ(); 109 | 110 | // Calculate a new radius based on Z 111 | const newRadius = 112 | (1 - mapNumberToRange(this.z, 0, maxZ, 0, 1)) * this.radius; 113 | 114 | // Calculate a new opacity based on Z 115 | var opacity = 116 | Math.round(10 - mapNumberToRange(this.z, 0, maxZ, 0, 10)) / 10; 117 | var trailOpacity = opacity / 4; 118 | 119 | // Draw star trail 120 | if (this.trails && this.lastX !== this.x) { 121 | this.ctx.lineWidth = newRadius; 122 | this.ctx.strokeStyle = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${trailOpacity})`; 123 | this.ctx.beginPath(); 124 | this.ctx.moveTo(newX, newY); 125 | this.ctx.lineTo(this.lastX, this.lastY); 126 | this.ctx.stroke(); 127 | } 128 | 129 | // Save drawing settings to restore after applying the glow to stars only 130 | if (this.glow) { 131 | this.ctx.save(); 132 | this.ctx.shadowBlur = 5; 133 | this.ctx.shadowColor = '#FFF'; 134 | } 135 | 136 | // Draw the star 137 | this.ctx.fillStyle = `rgb(${this.color.r}, ${this.color.g}, ${this.color.b}, ${opacity})`; 138 | this.ctx.beginPath(); 139 | this.ctx.arc(newX, newY, newRadius, 0, Math.PI * 2); 140 | this.ctx.fill(); 141 | 142 | // Undo glow settings 143 | if (this.glow) { 144 | this.ctx.restore(); 145 | } 146 | 147 | // Update last x/y 148 | this.lastX = newX; 149 | this.lastY = newY; 150 | } 151 | 152 | // (Re)set the star, either initially (init) or when reaching the depth limit 153 | reset(init = false) { 154 | // Define a new random position within the canvas, velocity, and radius 155 | this.x = Math.random() * this.W - this.hW; 156 | this.y = Math.random() * this.H - this.hH; 157 | this.v = Math.random() * (this.maxV - this.minV) + this.minV; 158 | this.radius = Number((Math.random() * 2 + 1).toPrecision(3)); 159 | 160 | // Clear last x/y so we don't draw a trail from end to new reset location 161 | this.lastX = this.x; 162 | this.lastY = this.y; 163 | 164 | // If not init (ie. not first run), send to furthest Z, otherwise randomize 165 | this.z = !init ? this.getInitialZ() : Math.random() * this.getInitialZ(); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { AnimLoopEngine } from 'anim-loop-engine'; 2 | 3 | import { Star } from './Star'; 4 | import { defaultColor, StarColorObj } from './starColor'; 5 | 6 | type StarFieldOpts = { 7 | followMouse?: boolean; 8 | followContext?: any; 9 | color?: StarColorObj; 10 | glow?: boolean; 11 | maxV?: number; 12 | minV?: number; 13 | numStars?: number; 14 | trails?: boolean; 15 | }; 16 | 17 | // StarField factory 18 | export class StarField { 19 | private defaultMaxV: number = 5; 20 | private defaultMinV: number = 2; 21 | private defaultNumStars: number = 400; 22 | 23 | private initialized: boolean = false; 24 | 25 | private canvasW: number = 0; 26 | private canvasH: number = 0; 27 | private canvasHalfW: number = 0; 28 | private canvasHalfH: number = 0; 29 | 30 | private offsetX: number = 0; 31 | private offsetY: number = 0; 32 | private offsetTX: number = 0; 33 | private offsetTY: number = 0; 34 | 35 | private stars: Star[] = []; 36 | 37 | private canvas: HTMLCanvasElement; 38 | private canvasRectLeft: number; 39 | private canvasRectTop: number; 40 | private ctx: any; 41 | 42 | private engine: AnimLoopEngine; 43 | 44 | private resizeTimeout: number = 0; 45 | 46 | color: StarColorObj; 47 | followContext: any; 48 | glow: boolean; 49 | minV: number; 50 | maxV: number; 51 | numStars: number; 52 | trails: boolean; 53 | 54 | constructor(canvasId: string, opts: StarFieldOpts = {}) { 55 | if (!canvasId) { 56 | throw 'First argument "id" is required'; 57 | return; 58 | } 59 | 60 | this.color = opts.color || defaultColor; 61 | this.glow = opts.glow || false; 62 | this.minV = opts.minV || this.defaultMinV; 63 | this.maxV = opts.maxV || this.defaultMaxV; 64 | this.numStars = this.defaultNumStars; 65 | this.trails = opts.trails || false; 66 | 67 | this.canvas = document.getElementById(canvasId); 68 | this.ctx = this.canvas.getContext('2d'); 69 | const rect = this.canvas.getBoundingClientRect(); 70 | this.canvasRectLeft = rect.left; 71 | this.canvasRectTop = rect.top; 72 | 73 | // Assign follow context now that this.canvas was assigned 74 | this.followContext = opts.followContext || this.canvas; 75 | 76 | this.handleMouseMove = this.handleMouseMove.bind(this); 77 | 78 | // Set up animation engine 79 | this.engine = new AnimLoopEngine(); 80 | this.engine.addTask(this.draw.bind(this)); 81 | 82 | // Set up window events 83 | // Window blur/focus 84 | window.addEventListener('blur', () => { 85 | this.stop(); 86 | }); 87 | window.addEventListener('focus', () => { 88 | this.start(); 89 | }); 90 | 91 | // Window event - on resize to reinitialize canvas, all stars and animation 92 | window.addEventListener('resize', () => { 93 | clearTimeout(this.resizeTimeout); 94 | this.stop(); 95 | this.resizeTimeout = setTimeout(() => { 96 | this.reset(); 97 | this.start(); 98 | }, 500); 99 | }); 100 | 101 | // Did config set a number of stars? 102 | this.numStars = opts.numStars 103 | ? Math.abs(opts.numStars) 104 | : this.defaultNumStars; 105 | 106 | // Setup the canvas 107 | this.setupCanvas(); 108 | 109 | // Gen new stars 110 | this.generateStars(); 111 | 112 | this.initialized = true; 113 | 114 | // Did config enable mouse following? 115 | if (opts.followMouse) { 116 | this.setFollowMouse(true); 117 | } 118 | } 119 | 120 | // Generate n new stars 121 | private generateStars() { 122 | for (let i = 0; i < this.numStars; i++) { 123 | this.stars.push( 124 | new Star({ 125 | ctx: this.ctx, 126 | W: this.canvasW, 127 | H: this.canvasH, 128 | hW: this.canvasHalfW, 129 | hH: this.canvasHalfH, 130 | minV: this.minV, 131 | maxV: this.maxV, 132 | color: this.color, 133 | glow: this.glow, 134 | trails: this.trails, 135 | addTasks: this.engine.addTasks 136 | }) 137 | ); 138 | } 139 | } 140 | 141 | // Apply canvas container size to canvas and translate origin to center 142 | private setupCanvas() { 143 | const canvasStyle: any = window.getComputedStyle(this.canvas); 144 | 145 | this.canvas.setAttribute('height', canvasStyle.height); 146 | this.canvas.setAttribute('width', canvasStyle.width); 147 | 148 | // canvasH/W/canvasHalfH/W used here and set to use elsewhere 149 | this.canvasH = this.canvas.height; 150 | this.canvasW = this.canvas.width; 151 | this.canvasHalfH = this.canvasH / 2; 152 | this.canvasHalfW = this.canvasW / 2; 153 | 154 | this.ctx.translate(this.canvasHalfW, this.canvasHalfH); 155 | } 156 | 157 | // Draw the stars in this frame 158 | private draw() { 159 | // Adjust offsets closer to target offset 160 | if (this.offsetX !== this.offsetTX) { 161 | this.offsetX += (this.offsetTX - this.offsetX) * 0.02; 162 | this.offsetY += (this.offsetTY - this.offsetY) * 0.02; 163 | } 164 | 165 | // Clear the canvas ready for this frame 166 | this.ctx.clearRect( 167 | -this.canvasHalfW, 168 | -this.canvasHalfH, 169 | this.canvasW, 170 | this.canvasH 171 | ); 172 | 173 | for (let i in this.stars) { 174 | this.stars[i].draw(this.offsetX, this.offsetY); 175 | } 176 | } 177 | 178 | // Follow mouse (used in event listener definition) 179 | private handleMouseMove(e: MouseEvent) { 180 | if (this.initialized) { 181 | this.offsetTX = e.clientX - this.canvasRectLeft - this.canvasHalfW; 182 | this.offsetTY = e.clientY - this.canvasRectTop - this.canvasHalfH; 183 | } 184 | } 185 | private resetMouseOffset() { 186 | this.offsetTX = 0; 187 | this.offsetTY = 0; 188 | } 189 | 190 | // Start/stop the StarField 191 | start() { 192 | this.engine.start(); 193 | } 194 | stop() { 195 | this.engine.stop(); 196 | } 197 | 198 | reset() { 199 | // Clear stars 200 | this.stars = []; 201 | 202 | // Reset canvas 203 | this.setupCanvas(); 204 | 205 | // Gen new stars 206 | this.generateStars(); 207 | } 208 | 209 | // "Hot"-updateable config values 210 | setMaxV(val: number) { 211 | this.maxV = val ? Math.abs(val) : this.defaultMaxV; 212 | this.reset(); 213 | } 214 | setMinV(val: number) { 215 | this.minV = val ? Math.abs(val) : this.defaultMinV; 216 | this.reset(); 217 | } 218 | setNumStars(val: number) { 219 | this.numStars = val ? Math.abs(val) : this.defaultNumStars; 220 | this.reset(); 221 | } 222 | setFollowMouse(val: boolean) { 223 | if (val) { 224 | this.followContext.addEventListener('mousemove', this.handleMouseMove); 225 | } else { 226 | this.followContext.removeEventListener('mousemove', this.handleMouseMove); 227 | this.resetMouseOffset(); 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/starColor.ts: -------------------------------------------------------------------------------- 1 | export type StarColorObj = { 2 | r: number; 3 | g: number; 4 | b: number; 5 | }; 6 | 7 | export const defaultColor = { 8 | r: 255, 9 | b: 255, 10 | g: 255 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "es2015", 5 | "strict": true, 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true 9 | } 10 | } 11 | --------------------------------------------------------------------------------