Skip to content

Instantly share code, notes, and snippets.

@cjhin
Last active May 26, 2018 05:45
Show Gist options
  • Save cjhin/94a6574f7b03e9c7159e067603566c42 to your computer and use it in GitHub Desktop.
Save cjhin/94a6574f7b03e9c7159e067603566c42 to your computer and use it in GitHub Desktop.
happy-sears-tower
<style>
html { height: 100%; }
body {
min-height: 100%;
margin: 0;
display: flex;
justify-content: center;
background: white;
font-family: sans-serif;
text-align: center;
cursor: move;
}
canvas {
display: block;
margin: 0px auto 20px;
}
.side {
position: absolute;
left: 20px;
}
a { color: #5AE; }
a:hover { color: #247; }
</style>
<div class="container">
<canvas></canvas>
</div>
<div class="side">
<p><a href="https://dribbble.com/shots/3611306-Happy-Town">Original design by Alex Pasquarella</a></p>
<p>Click &amp; drag to rotate</p>
<p><button class="reset-button">Reset</button></p>
</div>
<script>
// Hi! This 3D model was built using the <canvas> 2D drawing API.
// It uses lineWidth to give the illusion of form.
// I'm working on a library to make these sort of 3D illustrations,
// But it's not ready for prime-time. Stay tuned! *~ dd ~*
// -------------------------- utils -------------------------- //
var TAU = Math.PI * 2;
function extend( a, b ) {
for ( var prop in b ) {
a[ prop ] = b[ prop ];
}
return a;
}
function lerp( a, b, t ) {
return ( b - a ) * t + a;
}
function modulo( num, div ) {
return ( ( num % div ) + div ) % div;
}
// -------------------------- Vector3 -------------------------- //
function Vector3( position ) {
this.set( position );
}
Vector3.prototype.set = function( pos ) {
pos = Vector3.sanitize( pos );
this.x = pos.x;
this.y = pos.y;
this.z = pos.z;
return this;
};
Vector3.prototype.rotate = function( rotation ) {
if ( !rotation ) {
return;
}
this.rotateZ( rotation.z );
this.rotateY( rotation.y );
this.rotateX( rotation.x );
return this;
};
Vector3.prototype.rotateZ = function( angle ) {
rotateProperty( this, angle, 'x', 'y' );
};
Vector3.prototype.rotateX = function( angle ) {
rotateProperty( this, angle, 'y', 'z' );
};
Vector3.prototype.rotateY = function( angle ) {
rotateProperty( this, angle, 'x', 'z' );
};
function rotateProperty( vec, angle, propA, propB ) {
if ( angle % TAU === 0 ) {
return;
}
var cos = Math.cos( angle );
var sin = Math.sin( angle );
var a = vec[ propA ];
var b = vec[ propB ];
vec[ propA ] = a*cos - b*sin;
vec[ propB ] = b*cos + a*sin;
}
Vector3.prototype.add = function( vec ) {
if ( !vec ) {
return;
}
vec = Vector3.sanitize( vec );
this.x += vec.x;
this.y += vec.y;
this.z += vec.z;
return this;
};
Vector3.prototype.multiply = function( vec ) {
if ( !vec ) {
return;
}
vec = Vector3.sanitize( vec );
this.x *= vec.x;
this.y *= vec.y;
this.z *= vec.z;
return this;
};
Vector3.prototype.transform = function( translation, rotation, scale ) {
this.multiply( scale );
this.rotate( rotation );
this.add( translation );
};
Vector3.prototype.lerp = function( vec, t ) {
this.x = lerp( this.x, vec.x, t );
this.y = lerp( this.y, vec.y, t );
this.z = lerp( this.z, vec.z, t );
return this;
};
// ----- utils ----- //
// add missing properties
Vector3.sanitize = function( vec ) {
vec = vec || {};
vec.x = vec.x || 0;
vec.y = vec.y || 0;
vec.z = vec.z || 0;
return vec;
};
// -------------------------- Anchor -------------------------- //
function Anchor( options ) {
this.create( options );
}
Anchor.prototype.create = function( options ) {
// set defaults & options
extend( this, this.constructor.defaults );
options = options || {};
this.setOptions( this, options );
// transform
this.translate = new Vector3( options.translate );
this.rotate = new Vector3( options.rotate );
var scale = extend( { x: 1, y: 1, z: 1 }, options.scale );
this.scale = new Vector3( scale );
// origin
this.origin = new Vector3();
this.renderOrigin = new Vector3();
// children
this.children = [];
if ( this.addTo ) {
this.addTo.addChild( this );
}
};
Anchor.defaults = {};
Anchor.optionKeys = Object.keys( Anchor.defaults ).concat([
'rotate',
'translate',
'scale',
'addTo',
]);
Anchor.prototype.setOptions = function( item, options ) {
var optionKeys = this.constructor.optionKeys;
for ( var key in options ) {
if ( optionKeys.includes( key ) ) {
item[ key ] = options[ key ];
}
}
};
Anchor.prototype.addChild = function( shape ) {
this.children.push( shape );
};
// ----- update ----- //
Anchor.prototype.update = function() {
// update self
this.reset();
// update children
this.children.forEach( function( child ) {
child.update();
});
this.transform( this.translate, this.rotate, this.scale );
};
Anchor.prototype.reset = function() {
this.renderOrigin.set( this.origin );
};
Anchor.prototype.transform = function( translation, rotation, scale ) {
this.renderOrigin.transform( translation, rotation, scale );
// transform children
this.children.forEach( function( child ) {
child.transform( translation, rotation, scale );
});
};
Anchor.prototype.updateGraph = function() {
this.update();
this.checkFlatGraph();
this.flatGraph.forEach( function( item ) {
item.updateSortValue();
});
// z-sort
this.flatGraph.sort( sortBySortValue );
};
function sortBySortValue( a, b ) {
return b.sortValue - a.sortValue;
}
Anchor.prototype.checkFlatGraph = function() {
if ( !this.flatGraph ) {
this.updateFlatGraph();
}
};
Anchor.prototype.updateFlatGraph = function() {
this.flatGraph = this.getFlatGraph();
};
// return Array of self & all child graph items
Anchor.prototype.getFlatGraph = function() {
var flatGraph = [ this ];
this.children.forEach( function( child ) {
var childFlatGraph = child.getFlatGraph();
flatGraph = flatGraph.concat( childFlatGraph );
});
return flatGraph;
};
Anchor.prototype.updateSortValue = function() {
this.sortValue = this.renderOrigin.z;
};
// ----- render ----- //
Anchor.prototype.render = function() {};
Anchor.prototype.renderGraph = function( ctx ) {
this.checkFlatGraph();
this.flatGraph.forEach( function( item ) {
item.render( ctx );
});
};
// ----- misc ----- //
Anchor.prototype.copy = function( options ) {
// copy options
var itemOptions = {};
var optionKeys = this.constructor.optionKeys;
optionKeys.forEach( function( key ) {
itemOptions[ key ] = this[ key ];
}, this );
// add set options
this.setOptions( itemOptions, options );
var ItemClass = this.constructor;
return new ItemClass( itemOptions );
};
Anchor.prototype.normalizeRotate = function() {
this.rotate.x = modulo( this.rotate.x, TAU );
this.rotate.y = modulo( this.rotate.y, TAU );
this.rotate.z = modulo( this.rotate.z, TAU );
};
// ----- subclass ----- //
function getSubclass( Super ) {
return function( defaults ) {
// create constructor
function Item( options ) {
this.create( options );
}
Item.prototype = Object.create( Super.prototype );
Item.prototype.constructor = Item;
Item.defaults = extend( {}, Super.defaults );
Item.defaults = extend( Item.defaults, defaults );
Item.optionKeys = Super.optionKeys.slice(0)
.concat( Object.keys( Item.defaults ) );
Item.subclass = getSubclass( Item );
return Item;
};
}
Anchor.subclass = getSubclass( Anchor );
// -------------------------- PathAction -------------------------- //
function PathAction( method, points, previousPoint ) {
this.method = method;
this.points = points.map( mapVectorPoint );
this.renderPoints = points.map( mapVectorPoint );
this.previousPoint = previousPoint;
this.endRenderPoint = this.renderPoints[ this.renderPoints.length - 1 ];
// arc actions come with previous point & corner point
// but require bezier control points
if ( method == 'arc' ) {
this.controlPoints = [ new Vector3(), new Vector3() ];
}
}
function mapVectorPoint( point ) {
return new Vector3( point );
}
PathAction.prototype.reset = function() {
// reset renderPoints back to orignal points position
var points = this.points;
this.renderPoints.forEach( function( renderPoint, i ) {
var point = points[i];
renderPoint.set( point );
});
};
PathAction.prototype.transform = function( translation, rotation, scale ) {
this.renderPoints.forEach( function( renderPoint ) {
renderPoint.transform( translation, rotation, scale );
});
};
PathAction.prototype.render = function( ctx ) {
this[ this.method ]( ctx );
};
PathAction.prototype.move = function( ctx ) {
var point = this.renderPoints[0];
ctx.moveTo( point.x, point.y );
};
PathAction.prototype.line = function( ctx ) {
var point = this.renderPoints[0];
ctx.lineTo( point.x, point.y );
};
PathAction.prototype.bezier = function( ctx ) {
var cp0 = this.renderPoints[0];
var cp1 = this.renderPoints[1];
var end = this.renderPoints[2];
ctx.bezierCurveTo( cp0.x, cp0.y, cp1.x, cp1.y, end.x, end.y );
};
PathAction.prototype.arc = function( ctx ) {
var prev = this.previousPoint;
var corner = this.renderPoints[0];
var end = this.renderPoints[1];
var cp0 = this.controlPoints[0];
var cp1 = this.controlPoints[1];
cp0.set( prev ).lerp( corner, 9/16 );
cp1.set( end ).lerp( corner, 9/16 );
ctx.bezierCurveTo( cp0.x, cp0.y, cp1.x, cp1.y, end.x, end.y );
};
// -------------------------- Shape -------------------------- //
var Shape = Anchor.subclass({
stroke: true,
fill: false,
color: 'black',
lineWidth: 1,
closed: true,
rendering: true,
path: [ {} ],
front: { z: -1 },
});
var protoCreate = Anchor.prototype.create;
Shape.prototype.create = function( options ) {
Anchor.prototype.create.call( this, options );
this.updatePathActions();
// front
this.front = new Vector3( options.front || this.front );
this.renderFront = new Vector3( this.front );
};
var defaultShapeKeys = Object.keys( Shape.defaults );
Shape.optionKeys = Shape.optionKeys.concat( defaultShapeKeys ).concat([
'width',
'height',
'front',
'backfaceHidden',
]);
var actionNames = [
'move',
'line',
'bezier',
'arc',
];
// parse path into PathActions
Shape.prototype.updatePathActions = function() {
var previousPoint;
this.pathActions = this.path.map( function( pathPart, i ) {
// pathPart can be just vector coordinates -> { x, y, z }
// or path instruction -> { arc: [ {x0,y0,z0}, {x1,y1,z1} ] }
var keys = Object.keys( pathPart );
var method = keys[0];
var points = pathPart[ method ];
var isInstruction = keys.length === 1 && actionNames.includes( method ) &&
Array.isArray( points );
if ( !isInstruction ) {
method = 'line';
points = [ pathPart ];
}
// first action is always move
method = i === 0 ? 'move' : method;
// arcs require previous last point
var pathAction = new PathAction( method, points, previousPoint );
// update previousLastPoint
previousPoint = pathAction.endRenderPoint;
return pathAction;
});
};
// ----- update ----- //
Shape.prototype.reset = function() {
this.renderOrigin.set( this.origin );
this.renderFront.set( this.front );
// reset pathAction render points
this.pathActions.forEach( function( pathAction ) {
pathAction.reset();
});
};
Shape.prototype.transform = function( translation, rotation, scale ) {
// TODO, only transform these if backfaceHidden for perf?
this.renderOrigin.transform( translation, rotation, scale );
this.renderFront.transform( translation, rotation, scale );
// transform points
this.pathActions.forEach( function( pathAction ) {
pathAction.transform( translation, rotation, scale );
});
// transform children
this.children.forEach( function( child ) {
child.transform( translation, rotation, scale );
});
};
Shape.prototype.updateSortValue = function() {
var sortValueTotal = 0;
this.pathActions.forEach( function( pathAction ) {
sortValueTotal += pathAction.endRenderPoint.z;
});
// average sort value of all points
// def not geometrically correct, but works for me
this.sortValue = sortValueTotal / this.pathActions.length;
};
// ----- render ----- //
Shape.prototype.render = function( ctx ) {
var length = this.pathActions.length;
if ( !this.rendering || !length ) {
return;
}
// hide backface
var isFacingBack = this.renderFront.z > this.renderOrigin.z;
if ( this.backfaceHidden && isFacingBack ) {
return;
}
// render dot or path
var isDot = length == 1;
if ( isDot ) {
this.renderDot( ctx );
} else {
this.renderPath( ctx );
}
};
// Safari does not render lines with no size, have to render circle instead
Shape.prototype.renderDot = function( ctx ) {
ctx.fillStyle = this.color;
var point = this.pathActions[0].endRenderPoint;
ctx.beginPath();
var radius = this.lineWidth/2;
ctx.arc( point.x, point.y, radius, 0, TAU );
ctx.fill();
};
Shape.prototype.renderPath = function( ctx ) {
// set render properties
ctx.fillStyle = this.color;
ctx.strokeStyle = this.color;
ctx.lineWidth = this.lineWidth;
// render points
ctx.beginPath();
this.pathActions.forEach( function( pathAction ) {
pathAction.render( ctx );
});
var isTwoPoints = this.pathActions.length == 2 &&
this.pathActions[1].method == 'line';
if ( !isTwoPoints && this.closed ) {
ctx.closePath();
}
if ( this.stroke ) {
ctx.stroke();
}
if ( this.fill ) {
ctx.fill();
}
};
// -------------------------- Ellipse -------------------------- //
var Ellipse = Shape.subclass({
width: 1,
height: 1,
closed: false,
});
Ellipse.optionKeys = Ellipse.optionKeys.concat([
'width',
'height',
]);
var protoCreate = Ellipse.prototype.create;
Ellipse.prototype.create = function( options ) {
options.path = getEllipsePath( options );
protoCreate.call( this, options );
};
function getEllipsePath( options ) {
var x = options.width / 2;
var y = options.height / 2;
var path = [
{ x: 0, y: -y },
{ arc: [ // top right
{ x: x, y: -y },
{ x: x, y: 0 },
]},
{ arc: [ // bottom right
{ x: x, y: y },
{ x: 0, y: y },
]},
{ arc: [ // bottom left
{ x: -x, y: y },
{ x: -x, y: 0 },
]},
{ arc: [ // bottom left
{ x: -x, y: -y },
{ x: 0, y: -y },
]},
];
return path;
}
// -------------------------- Rect -------------------------- //
var Rect = Shape.subclass();
Rect.optionKeys = Rect.optionKeys.concat([
'width',
'height',
]);
var protoCreate = Rect.prototype.create;
Rect.prototype.create = function( options ) {
options.path = getRectPath( options );
protoCreate.call( this, options );
};
function getRectPath( options ) {
var w = ( options.width || 1 ) / 2;
var h = ( options.height || 1 ) / 2;
var path = [
{ x: -w, y: -h },
{ x: w, y: -h },
{ x: w, y: h },
{ x: -w, y: h },
];
return path;
}
// -------------------------- Group -------------------------- //
var Group = Anchor.subclass({
updateSort: false,
});
// ----- update ----- //
Group.prototype.updateSortValue = function() {
var sortValueTotal = 0;
this.checkFlatGraph();
this.flatGraph.forEach( function( item ) {
item.updateSortValue();
sortValueTotal += item.sortValue;
});
// average sort value of all points
// def not geometrically correct, but works for me
this.sortValue = sortValueTotal / this.flatGraph.length;
if ( this.updateSort ) {
this.flatGraph.sort( function( a, b ) {
return b.sortValue - a.sortValue;
});
}
};
// ----- render ----- //
Group.prototype.render = function( ctx ) {
this.checkFlatGraph();
this.flatGraph.forEach( function( item ) {
item.render( ctx );
});
};
// do not include children, group handles rendering & sorting internally
Group.prototype.getFlatGraph = function() {
return [ this ];
};
Group.prototype.checkFlatGraph = function() {
if ( !this.flatGraph ) {
this.updateFlatGraph();
}
};
Group.prototype.updateFlatGraph = function() {
this.flatGraph = this.getChildFlatGraph();
};
// get flat graph only used for group
// do not include in parent flatGraphs
Group.prototype.getChildFlatGraph = function() {
// do not include self
var flatGraph = [];
this.children.forEach( function( child ) {
var childFlatGraph = child.getFlatGraph();
flatGraph = flatGraph.concat( childFlatGraph );
});
return flatGraph;
};
// -------------------------- Dragger -------------------------- //
// quick & dirty drag event stuff
// messes up if multiple pointers/touches
// event support, default to mouse events
var downEvent = 'mousedown';
var moveEvent = 'mousemove';
var upEvent = 'mouseup';
if ( window.PointerEvent ) {
// PointerEvent, Chrome
downEvent = 'pointerdown';
moveEvent = 'pointermove';
upEvent = 'pointerup';
} else if ( 'ontouchstart' in window ) {
// Touch Events, iOS Safari
downEvent = 'touchstart';
moveEvent = 'touchmove';
upEvent = 'touchend';
}
function noop() {}
function Dragger( options ) {
this.startElement = options.startElement;
this.onPointerDown = options.onPointerDown || noop;
this.onPointerMove = options.onPointerMove || noop;
this.onPointerUp = options.onPointerUp || noop;
this.startElement.addEventListener( downEvent, this );
}
Dragger.prototype.handleEvent = function( event ) {
var method = this[ 'on' + event.type ];
if ( method ) {
method.call( this, event );
}
};
Dragger.prototype.onmousedown =
Dragger.prototype.onpointerdown = function( event ) {
this.pointerDown( event, event );
};
Dragger.prototype.ontouchstart = function( event ) {
this.pointerDown( event, event.changedTouches[0] );
};
Dragger.prototype.pointerDown = function( event, pointer ) {
event.preventDefault();
this.dragStartX = pointer.pageX;
this.dragStartY = pointer.pageY;
window.addEventListener( moveEvent, this );
window.addEventListener( upEvent, this );
this.onPointerDown( pointer );
};
Dragger.prototype.ontouchmove = function( event ) {
// HACK, moved touch may not be first
this.pointerMove( event, event.changedTouches[0] );
};
Dragger.prototype.onmousemove =
Dragger.prototype.onpointermove = function( event ) {
this.pointerMove( event, event );
};
Dragger.prototype.pointerMove = function( event, pointer ) {
event.preventDefault();
var moveX = pointer.pageX - this.dragStartX;
var moveY = pointer.pageY - this.dragStartY;
this.onPointerMove( pointer, moveX, moveY );
};
Dragger.prototype.onmouseup =
Dragger.prototype.onpointerup =
Dragger.prototype.ontouchend =
Dragger.prototype.pointerUp = function( event ) {
window.removeEventListener( moveEvent, this );
window.removeEventListener( upEvent, this );
this.onPointerUp( event );
};
//-------- colors --------//
var red = '#F44';
var navy = '#247';
var blue = '#5AE';
var gold = '#FB3';
var white = 'white';
/* globals red, blue, navy, gold, white */
// -------------------------- makeBuilding -------------------------- //
function makeBuilding( options ) {
var wallX = options.width/2;
var wallY = options.height;
var wallZ = options.depth/2;
// collect walls
var building = {};
// south/noth walls
[ true, false ].forEach( function( isSouth ) {
var wallTZ = isSouth ? -wallZ : wallZ;
var wallGroup = new Group({
addTo: options.addTo,
translate: { z: wallTZ },
});
var wallPath = [
{ x: -wallX, y: -wallY }
];
if ( options.gable == 'ns' ) {
wallPath.push({ x: 0, y: -wallY - wallX });
}
wallPath = wallPath.concat([
{ x: wallX, y: -wallY },
{ x: wallX, y: 0 },
{ x: -wallX, y: 0 },
]);
// wall
new Shape({
path: wallPath,
addTo: wallGroup,
color: isSouth ? red : gold,
});
var windowColor = isSouth ? navy : red;
var windowProperty = isSouth ? 'southWindows' : 'northWindows';
handleWindows( options, windowProperty, wallGroup, windowColor );
var wallProperty = isSouth ? 'southWall' : 'northWall';
building[ wallProperty ] = wallGroup;
});
// east/west wall
[ true, false ].forEach( function( isWest ) {
var wallGroup = new Group({
addTo: options.addTo,
translate: { x: isWest ? -wallX : wallX },
rotate: { y: TAU/4 },
});
var wallPath = [
{ x: -wallZ, y: -wallY }
];
if ( options.gable == 'ew' ) {
wallPath.push({ x: 0, y: -wallY - wallZ });
}
wallPath = wallPath.concat([
{ x: wallZ, y: -wallY },
{ x: wallZ, y: 0 },
{ x: -wallZ, y: 0 },
]);
// wall
new Shape({
path: wallPath,
addTo: wallGroup,
color: isWest ? blue : white,
});
var windowColor = isWest ? navy : blue;
var windowProperty = isWest ? 'westWindows' : 'eastWindows';
handleWindows( options, windowProperty, wallGroup, windowColor );
var wallProperty = isWest ? 'westWall' : 'eastWall';
building[ wallProperty ] = wallGroup;
});
var roofMakers = {
ns: function() {
var y0 = -wallY - wallX;
var roofPanel = new Shape({
path: [
{ x: 0, y: y0, z: -wallZ },
{ x: 0, y: y0, z: wallZ },
{ x: wallX, y: -wallY, z: wallZ },
{ x: wallX, y: -wallY, z: -wallZ },
],
addTo: options.addTo,
color: gold,
});
roofPanel.copy({
scale: { x: -1 },
color: navy,
});
},
ew: function() {
var y0 = -wallY - wallZ;
var xA = options.isChurch ? -wallX + 8 : -wallX;
var roofPanel = new Shape({
path: [
{ z: 0, y: y0, x: xA },
{ z: 0, y: y0, x: wallX },
{ z: wallZ, y: -wallY, x: wallX },
{ z: wallZ, y: -wallY, x: xA },
],
addTo: options.addTo,
color: red,
});
roofPanel.copy({
path: [
{ z: 0, y: y0, x: -wallX },
{ z: 0, y: y0, x: wallX },
{ z: wallZ, y: -wallY, x: wallX },
{ z: wallZ, y: -wallY, x: -wallX },
],
scale: { z: -1 },
color: navy,
});
},
none: function() {
var y0 = -wallY;
var roofPanel = new Shape({
path: [
{ z: -wallZ, y: y0, x: -wallX },
{ z: -wallZ, y: y0, x: wallX },
{ z: wallZ, y: -wallY, x: wallX },
{ z: wallZ, y: -wallY, x: -wallX },
],
addTo: options.addTo,
color: gold,
});
},
};
var roofMaker = roofMakers[ options.gable ];
if ( roofMaker ) {
roofMaker();
}
return building;
}
function handleWindows( options, windowProperty, wallGroup, color ) {
var windowOption = options[ windowProperty ];
if ( !windowOption ) {
return;
}
var columns = windowOption[0];
var rows = windowOption[1];
// var windowPaths = [];
for ( var row=0; row < rows; row++ ) {
for ( var col=0; col < columns; col++ ) {
var x = ( col - (columns-1)/2 ) * 6;
var y = -options.height + (row + 0.75) * 8;
var windowPath = [
{ x: x + -1, y: y + -2 },
{ x: x + 1, y: y + -2 },
{ x: x + 1, y: y + 2 },
{ x: x + -1, y: y + 2 },
];
new Shape({
path: windowPath,
addTo: wallGroup,
color: color,
});
}
}
}
// -------------------------- lilPyramid -------------------------- //
function lilPyramid( options ) {
var anchor = new Anchor({
addTo: options.addTo,
translate: options.translate,
});
var panel = new Shape({
path: [
{ x: 0, y: -3, z: 0 },
{ x: 3, y: 0, z: 0 },
{ x: 0, y: 0, z: 3 },
],
addTo: anchor,
color: red,
});
panel.copy({
rotate: { y: TAU/4 },
color: red,
});
panel.copy({
rotate: { y: TAU/2 },
color: navy,
});
panel.copy({
rotate: { y: TAU * 3/4 },
color: navy,
});
}
function hedge( options ) {
var anchor = new Anchor({
addTo: options.addTo,
translate: options.translate,
});
var ball = new Shape({
path: [ { y: 0 }, { y: -1 } ],
lineWidth: 5,
addTo: anchor,
translate: { y: -2.5 },
stroke: true,
color: options.color || navy,
});
ball.copy({
lineWidth: 4,
translate: { y: -5 },
});
ball.copy({
lineWidth: 2.5,
translate: { y: -7.5 },
});
}
// -------------------------- demo -------------------------- //
var canvas = document.querySelector('canvas');
var ctx = canvas.getContext('2d');
var w = 160;
var h = 400;
var minWindowSize = Math.min( window.innerWidth, window.innerHeight );
var zoom = Math.min( 2, Math.floor( minWindowSize / w ) );
var pixelRatio = window.devicePixelRatio || 1;
zoom *= pixelRatio;
var canvasWidth = canvas.width = w * zoom;
var canvasHeight = canvas.height = h * zoom;
// set canvas screen size
if ( pixelRatio > 1 ) {
canvas.style.width = canvasWidth / pixelRatio + 'px';
canvas.style.height = canvasHeight / pixelRatio + 'px';
}
var isRotating = true;
// default to flat, filled shapes
[ Shape, Rect, Ellipse ].forEach( function( ItemClass ) {
ItemClass.defaults.fill = true;
ItemClass.defaults.stroke = false;
});
var camera = new Anchor({
rotate: { y: -TAU/8 },
});
// -- illustration shapes --- //
var quarterView = 1/Math.sin(TAU/8);
// anchor
var town = new Group({
addTo: camera,
translate: { y: 36 },
scale: { x: quarterView, z: quarterView },
updateSort: true,
});
// ----- front building ----- //
var frontAnchor = new Anchor({
addTo: town,
translate: { x: 0, y: -4, z: 0 },
});
var bundleWidth = 12;
var numWindows = bundleWidth / 6;
var mainBundle = makeBuilding({
width: bundleWidth,
depth: bundleWidth * 2,
height: bundleWidth * 18,
addTo: frontAnchor,
gable: 'none',
southWindows: [ numWindows, bundleWidth * (13/6) ],
eastWindows: [ numWindows * 2, bundleWidth * (6/5) ],
westWindows: [ numWindows * 2, bundleWidth * (12/11) ],
northWindows: [ numWindows, bundleWidth * (1/3) ],
});
var secondBundleEast = makeBuilding({
width: bundleWidth,
depth: bundleWidth,
height: bundleWidth * 15,
addTo: new Anchor({
addTo: town,
translate: { x: bundleWidth, y: -4, z: bundleWidth / 2.0 },
}),
gable: 'none',
southWindows: [ numWindows, bundleWidth / 2 ],
eastWindows: [ numWindows, bundleWidth * (15 / 8) - 1 ],
northWindows: [ numWindows, bundleWidth * (12 / 15) ],
});
var secondBundleNorth = makeBuilding({
width: bundleWidth,
depth: bundleWidth,
height: bundleWidth * 15,
addTo: new Anchor({
addTo: town,
translate: { x: 0, y: -4, z: 3/2*bundleWidth },
}),
gable: 'none',
eastWindows: [ numWindows, bundleWidth * (12/15) ],
westWindows: [ numWindows, bundleWidth * (1/2) ],
northWindows: [ numWindows, bundleWidth * (15/8) - 1 ],
});
var secondBundleWest = makeBuilding({
width: bundleWidth,
depth: bundleWidth,
height: bundleWidth * 15,
addTo: new Anchor({
addTo: town,
translate: { x: -1.0 * bundleWidth, y: -4, z: bundleWidth / 2.0 },
}),
gable: 'none',
southWindows: [ numWindows, bundleWidth * (15 / 18) ],
westWindows: [ numWindows, bundleWidth * (15 / 8) ],
northWindows: [ numWindows, bundleWidth * (15 / 35) ],
});
var thirdBundleSouthEast = makeBuilding({
width: bundleWidth,
depth: bundleWidth,
height: bundleWidth * 11,
addTo: new Anchor({
addTo: town,
translate: { x: bundleWidth, y: -4, z: bundleWidth / -2.0 },
}),
gable: 'none',
southWindows: [ numWindows, bundleWidth * (11 / 9) + 1 ],
eastWindows: [ numWindows, bundleWidth * (11 / 9) + 1],
});
var thirdBundleNorthWest = makeBuilding({
width: bundleWidth,
depth: bundleWidth,
height: bundleWidth * 11,
addTo: new Anchor({
addTo: town,
translate: { x: -bundleWidth, y: -4, z: 3/2*bundleWidth },
}),
gable: 'none',
westWindows: [ numWindows, bundleWidth * (11 / 9) + 1 ],
northWindows: [ numWindows, bundleWidth * (11 / 9) + 1 ],
});
var fourthBundleSouthWest = makeBuilding({
width: bundleWidth,
depth: bundleWidth,
height: bundleWidth * 8.3,
addTo: new Anchor({
addTo: town,
translate: { x: -1.0 * bundleWidth, y: -4, z: bundleWidth / -2.0 },
}),
gable: 'none',
southWindows: [ numWindows, bundleWidth ],
westWindows: [ numWindows, bundleWidth ],
});
var fourthBundleNorthEast = makeBuilding({
width: bundleWidth,
depth: bundleWidth,
height: bundleWidth * 8.3,
addTo: new Anchor({
addTo: town,
translate: { x: bundleWidth, y: -4, z: 3/2*bundleWidth },
}),
gable: 'none',
eastWindows: [ numWindows, bundleWidth ],
northWindows: [ numWindows, bundleWidth ],
});
// east gable dot
var gableDot = new Ellipse({
width: 2,
height: 2,
//addTo: mainBundle.eastWall,
color: blue,
translate: { y: -20 },
});
/*
// west gable dot
gableDot.copy({
addTo: mainBundle.westWall,
color: navy,
});
*/
// south doors
var door = new Shape({
path: [
{ x: -2.5, y: 0 },
{ x: -2.5, y: -5.5 },
{ arc: [
{ x: -2.5, y: -8 },
{ x: 0, y: -8 },
]},
{ arc: [
{ x: 2.5, y: -8 },
{ x: 2.5, y: -5.5 },
]},
{ x: 2.5, y: 0 },
],
addTo: mainBundle.southWall,
translate: { x: 0 },
color: navy,
});
// ----- hedges ----- //
// to right of front building
hedge({
addTo: town,
translate: { x: 34, y: -4, z: -4 },
color: gold
});
// right of church
hedge({
addTo: town,
translate: { x: -4, y: -4, z: -24 },
color: gold,
});
// in between tower & church
hedge({
addTo: town,
translate: { x: -30, y: -4, z: 18 },
color: gold,
});
hedge({
addTo: town,
translate: { x: 0, y: -220, z: 6},
color: navy,
});
hedge({
addTo: town,
translate: { x: 0, y: -220, z: -6},
color: navy,
});
// ----- sun ----- //
new Shape({
addTo: town,
translate: { x: -6, y: bundleWidth * -17, z: 42 },
lineWidth: 6,
stroke: true,
color: gold,
});
// ----- sky particles ----- //
// dot above left building
var skyDot = new Shape({
translate: { x: -3, y: bundleWidth * -12, z: -42 },
addTo: town,
lineWidth: 2,
stroke: true,
color: white,
});
// in front of church
skyDot.copy({
translate: { x: 30, y: bundleWidth * -13 + 4, z: 28 },
});
var skyDiamond = new Shape({
path: [
{ x: 0, y: -1 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: -1, y: 0 },
],
addTo: town,
translate: { x: -27, y: bundleWidth * -12, z: -29 },
scale: { x: 0.75, y: 0.75 },
stroke: true,
lineWidth: 0.5,
color: white,
});
skyDiamond.copy({
rotate: { y: TAU/4 },
});
var skyDiamond2 = skyDiamond.copy({
translate: { x: 8, y: bundleWidth * -13, z: 42 },
});
skyDiamond2.copy({
rotate: { y: TAU/4, },
});
var skyStar = new Shape({
path: [
{ x: 0, y: -1 },
{ arc: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
]},
{ arc: [
{ x: 0, y: 0 },
{ x: 0, y: 1 },
]},
{ arc: [
{ x: 0, y: 0 },
{ x: -1, y: 0 },
]},
{ arc: [
{ x: 0, y: 0 },
{ x: 0, y: -1 },
]},
],
addTo: town,
translate: { x: -39, y: bundleWidth * -15, z: -12 },
scale: { x: 1.5, y: 1.5 },
stroke: true,
lineWidth: 1,
color: white,
});
skyStar.copy({
rotate: { y: TAU/4 },
});
// up front
var skyStar2 = skyStar.copy({
translate: { x: 29, y: bundleWidth * -13, z: -30 },
color: white,
});
skyStar2.copy({
rotate: { y: TAU/4 },
});
// ----- clouds ----- //
var cloud = new Shape({
path: [
{ x: -1, y: 0 },
{ arc: [
{ x: -1, y: -1 },
{ x: 0, y: -1 },
]},
{ arc: [
{ x: 1, y: -1 },
{ x: 1, y: 0 },
]},
],
addTo: town,
translate: { x: -30, y: bundleWidth * -15, z: -10 },
scale: { x: 1.5, y: 1.5 },
rotate: { y: TAU/4 },
stroke: true,
lineWidth: 2,
color: white,
});
cloud.copy({
translate: { x: -30, y: bundleWidth * -15 + 2, z: -6 },
});
cloud.copy({
translate: { x: -30, y: bundleWidth * -15 + 1, z: -2 },
});
// line underneath
cloud.copy({
path: [ { x: -1 }, { x: 1 } ],
translate: { x: -30, y: bundleWidth * -15, z: -6 },
scale: { x: 2 },
});
// ----- flat earth ----- //
var flatEarth = new Ellipse({
width: 128,
height: 128,
addTo: camera,
translate: town.translate,
rotate: { x: TAU/4 },
lineWidth: 8,
stroke: true,
color: navy,
});
// ----- sky ----- //
var sky = new Group({
addTo: camera,
translate: town.translate,
translate: { y: bundleWidth * -8 },
});
( function() {
var topYs = [
-64, -64, -52, -52,
-44, -44, -36, -36,
-44, -44, -52, -52,
-60, -60, -52, -52,
];
var bottomYs = [
-4, -4, 6, 6,
18, 18, 20, 20,
18, 18, 6, 6,
-4, -4, -12, -12,
];
var radius = 64;
var skyPanelCount = topYs.length;
var angle = TAU / skyPanelCount;
var panelWidth = Math.tan( angle/2 ) * radius * 2;
for ( var i=0; i < skyPanelCount; i++ ) {
var nextI = (i + 1) % skyPanelCount;
var topYA = topYs[ i ];
var topYB = topYs[ nextI ];
var bottomYA = bottomYs[ i ];
var bottomYB = bottomYs[ nextI ];
var panelAnchor = new Anchor({
addTo: sky,
rotate: { y: angle * -i + TAU/4 },
translate: { y: 1 },
});
new Shape({
path: [
{ x: -panelWidth/2, y: topYA },
{ bezier: [
{ x: 0, y: topYA },
{ x: 0, y: topYB },
{ x: panelWidth/2, y: topYB },
]},
{ x: panelWidth/2, y: bottomYB },
{ bezier: [
{ x: 0, y: bottomYB },
{ x: 0, y: bottomYA },
{ x: -panelWidth/2, y: bottomYA },
]},
],
addTo: panelAnchor,
translate: { z: radius },
color: blue,
stroke: true,
lineWidth: 1,
backfaceHidden: true,
});
}
})();
// -- animate --- //
var t = 0;
var tSpeed = 1/240;
var then = new Date() - 1/60;
function animate() {
update();
render();
requestAnimationFrame( animate );
}
animate();
// -- update -- //
// i, 0->1
function easeInOut( i ) {
i = i % 1;
var isFirstHalf = i < 0.5;
var i1 = isFirstHalf ? i : 1 - i;
i1 = i1 / 0.5;
// make easing steeper with more multiples
var i2 = i1 * i1;
i2 = i2 / 2;
return isFirstHalf ? i2 : i2*-1 + 1;
}
function update() {
var now = new Date();
var delta = now - then;
if ( isRotating ) {
t += tSpeed * delta/60;
var theta = easeInOut( t ) * TAU;
var rev = 1;
var spin = -theta * rev - TAU/8;
var extraRotation = TAU * rev * Math.floor( ( t % 4 ) );
camera.rotate.y = spin - extraRotation;
camera.rotate.x = t % 2 < 1 ? 0 : ( Math.cos( theta ) * -0.5 + 0.5 ) * TAU * 3/16;
}
camera.normalizeRotate();
// rotate
camera.updateGraph();
then = now;
}
// -- render -- //
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
function render() {
ctx.clearRect( 0, 0, canvasWidth, canvasHeight );
ctx.save();
ctx.scale( zoom, zoom );
ctx.translate( w/2, h/2 );
var isCameraXUp = camera.rotate.x > 0 && camera.rotate.x < TAU/2;
sky.renderGraph( ctx );
// HACK sort flat earth & town shapes manually
if ( isCameraXUp ) {
flatEarth.render( ctx );
}
town.renderGraph( ctx );
if ( !isCameraXUp ) {
flatEarth.render( ctx );
}
ctx.restore();
}
// ----- inputs ----- //
// click drag to rotate
var dragStartAngleX, dragStartAngleY;
new Dragger({
startElement: canvas,
onPointerDown: function() {
isRotating = false;
dragStartAngleX = camera.rotate.x;
dragStartAngleY = camera.rotate.y;
},
onPointerMove: function( pointer, moveX, moveY ) {
var angleXMove = moveY / canvasWidth * TAU;
var angleYMove = moveX / canvasWidth * TAU;
camera.rotate.x = dragStartAngleX + angleXMove;
camera.rotate.y = dragStartAngleY + angleYMove;
},
});
document.querySelector('.reset-button').onclick = function() {
isRotating = false;
camera.rotate.set({ x: 0, y: -TAU/8 });
};
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment