A Pen by Mitsuo Utano on CodePen.
Created
January 3, 2016 12:47
-
-
Save utano320/a4f450e51240bbf9841c to your computer and use it in GitHub Desktop.
canvasを使って3次元上の平面をスクリーンに描画する(透視投影変換)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<html lang="ja"> | |
<head> | |
<meta charset="utf-8"> | |
<title>canvasを使って3次元上の平面をスクリーンに描画する(透視投影変換)</title> | |
</head> | |
<link rel="stylesheet" href="style.css" /> | |
<body> | |
<div id="canvasWrapper"> | |
<canvas id="axis2DCanvas"></canvas> | |
<div id="arrowBox">⇒</div> | |
<canvas id="axis3DCanvas"></canvas> | |
</div> | |
<script src="script.js"></script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var canvas2D; // 2次座標系用canvas要素(HTMLCanvasElement) | |
var canvas3D; // 3次座標系用canvas要素(HTMLCanvasElement) | |
var sX3D = null; // ドラッグ開始時点のx座標(スクリーン座標) | |
var sY3D = null; // ドラッグ開始時点のy座標(スクリーン座標) | |
var sT3D = null; // ドラッグ開始時点のθ | |
var sP3D = null; // ドラッグ開始時点のφ | |
var mouseX2D; // 2次座標系用ドラッグされている位置のx座標(スクリーン座標) | |
var mouseY2D; // 2次座標系用ドラッグされている位置のy座標(スクリーン座標) | |
var mouseX3D; // 3次座標系用ドラッグされている位置のx座標(スクリーン座標) | |
var mouseY3D; // 3次座標系用ドラッグされている位置のy座標(スクリーン座標) | |
// 2次元座標系 | |
var TwoDim = function(canvas) { | |
this.width = canvas.width; | |
this.height = canvas.height; | |
this.ctx = canvas.getContext('2d'); | |
// 中心Oの座標(スクリーン座標) | |
this.oX = Math.floor(this.width / 2); | |
this.oY = Math.ceil(this.height / 2); | |
// 面確定フラグ(trueの時は新しい頂点を追加できない) | |
this.commit_flg = false; | |
// 頂点(Point3D)を格納する配列 | |
this.point = []; | |
// 平面の方程式(ax+by+cz+d=0)に必要なパラメータ | |
this.a = 0; | |
this.b = 0; | |
this.c = 0; | |
this.d = 0; | |
// 平面の方程式を求める | |
this.calcEquation = function() { | |
// 頂点の数が3の場合のみ計算可能 | |
if (this.point.length != 3) return; | |
var x0 = this.point[0].x, | |
y0 = this.point[0].y, | |
z0 = this.point[0].z; | |
var x1 = this.point[1].x, | |
y1 = this.point[1].y, | |
z1 = this.point[1].z; | |
var x2 = this.point[2].x, | |
y2 = this.point[2].y, | |
z2 = this.point[2].z; | |
// パラメータ計算 | |
this.a = (y1 - y0) * (z2 - z0) - (z1 - z0) * (y2 - y0); | |
this.b = (z1 - z0) * (x2 - x0) - (x1 - x0) * (z2 - z0); | |
this.c = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0); | |
this.d = -this.a * x0 - this.b * y0 - this.c * z0; | |
} | |
// 平面の方程式を使ってz座標を算出 | |
this.calcZ = function(x, y) { | |
return -(this.d + this.a * x + this.b * y) / this.c; | |
} | |
// 描画処理 | |
this.repaint = function() { | |
// 一度描画をクリア | |
this.ctx.clearRect(0, 0, this.width, this.height); | |
// 座標軸の描画 | |
this.drawAxis(); | |
// 面の描画 | |
this.drawSurface(); | |
} | |
// 座標軸の描画 | |
this.drawAxis = function() { | |
this.ctx.strokeStyle = '#999'; | |
this.ctx.lineWidth = 1; | |
// x座標軸を描画 | |
this.ctx.beginPath(); | |
this.ctx.moveTo(0, this.oY); | |
this.ctx.lineTo(this.width, this.oY); | |
this.ctx.stroke(); | |
// y座標軸を描画 | |
this.ctx.beginPath(); | |
this.ctx.moveTo(this.oX, 0); | |
this.ctx.lineTo(this.oX, this.height); | |
this.ctx.stroke(); | |
this.ctx.fillStyle = "#999"; | |
// x座標軸の矢印を描画 | |
this.ctx.beginPath(); | |
this.ctx.moveTo(this.width, this.oY); | |
this.ctx.lineTo(this.width - 10, this.oY - 7); | |
this.ctx.lineTo(this.width - 10, this.oY + 7); | |
this.ctx.fill(); | |
// y座標軸の矢印を描画 | |
this.ctx.beginPath(); | |
this.ctx.moveTo(this.oX, 0); | |
this.ctx.lineTo(this.oX - 7, 10); | |
this.ctx.lineTo(this.oX + 7, 10); | |
this.ctx.fill(); | |
// 原点を表す文字「O」を描画 | |
this.ctx.beginPath(); | |
var maxWidth = 100; | |
this.ctx.font = "12px 'Verdana'"; | |
this.ctx.textAlign = 'right'; | |
this.ctx.fillText('O', this.oX - 5, this.oY + 15, maxWidth); | |
} | |
// 面の描画 | |
this.drawSurface = function() { | |
var length = this.point.length; | |
if (length === 0) return; | |
for (var i = 0; i < length; i++) { | |
// 頂点の描画 | |
this.drawPoint(this.point[i].x, this.point[i].y, '#000', false); | |
if (i != 0) { | |
// 線分の描画 | |
this.drawLine(i - 1, i, '#000', 3); | |
} | |
} | |
// 多角形を作れる場合は、最初と最後の頂点を結ぶ線分を描画 | |
if (length >= 3) { | |
if (this.commit_flg) { | |
// 面が確定している場合 | |
this.drawLine(length - 1, 0, '#000', 3); | |
} else { | |
// 面が未確定の場合 | |
this.drawLine(length - 1, 0, '#F66', 1); | |
} | |
} | |
} | |
// 指定位置に点と座標表示を描画 | |
this.drawPoint = function(x, y, color, pointText) { | |
if (pointText === undefined) { | |
pointText = ''; | |
} | |
this.ctx.fillStyle = color; | |
// 指定位置を中心に円を描画 | |
this.ctx.beginPath(); | |
var screenX = this.oX + x; | |
var screenY = this.oY - y; | |
this.ctx.arc(screenX, screenY, 5, 0, Math.PI * 2, false); | |
this.ctx.fill(); | |
// 座標の表示テキストを描画 | |
if (pointText !== false) { | |
var maxWidth = 100; | |
if (x >= 0) { | |
// xが正(第一象限、第四象限)の場合は点の左側に座標を描画 | |
this.ctx.textAlign = 'right'; | |
this.ctx.fillText(pointText + '( ' + x + ', ' + y + ' )', screenX - 10, screenY + 3, maxWidth); | |
} else { | |
// xが負(第二象限、第三象限)の場合は点の右側に座標を描画 | |
this.ctx.textAlign = 'left'; | |
this.ctx.fillText(pointText + '( ' + x + ', ' + y + ' )', screenX + 10, screenY + 3, maxWidth); | |
} | |
} | |
} | |
// 指定された2つの点を結ぶ線分を描画 | |
this.drawLine = function(indexStartPoint, indexEndPoint, color, width) { | |
var startPoint = this.point[indexStartPoint]; | |
var endPoint = this.point[indexEndPoint]; | |
this.ctx.strokeStyle = color; | |
this.ctx.lineWidth = width; | |
// 線分を描画 | |
this.ctx.beginPath(); | |
this.ctx.moveTo(this.oX + startPoint.x, this.oY - startPoint.y); | |
this.ctx.lineTo(this.oX + endPoint.x, this.oY - endPoint.y); | |
this.ctx.stroke(); | |
} | |
// マウス位置に点を描画 | |
this.drawTempPoint = function(mouseX, mouseY) { | |
this.drawPoint(mouseX - this.oX, this.oY - mouseY, '#999'); | |
} | |
// マウス位置に向けて線分を描画 | |
this.drawTempLine = function(mouseX, mouseY) { | |
var length = this.point.length; | |
if (length == 0) { | |
return; | |
} | |
var startPoint = this.point[0]; | |
var endPoint = this.point[length - 1]; | |
this.ctx.strokeStyle = '#999'; | |
this.ctx.lineWidth = 2; | |
// 線分を描画 | |
this.ctx.beginPath(); | |
this.ctx.moveTo(mouseX, mouseY); | |
this.ctx.lineTo(this.oX + startPoint.x, this.oY - startPoint.y); | |
this.ctx.stroke(); | |
this.ctx.beginPath(); | |
this.ctx.moveTo(mouseX, mouseY); | |
this.ctx.lineTo(this.oX + endPoint.x, this.oY - endPoint.y); | |
this.ctx.stroke(); | |
} | |
// 頂点の追加 | |
this.addPoint = function(x, y, z) { | |
this.point.push(new Point3D(x, y, z)); | |
} | |
// 頂点の削除 | |
this.deletePoint = function(index) { | |
this.point.splice(index, 1); | |
} | |
// 面の描画確定 | |
this.commit = function() { | |
this.commit_flg = true; | |
this.repaint(); | |
} | |
// 面の描画確定を解除 | |
this.cancel = function() { | |
this.commit_flg = false; | |
this.repaint(); | |
} | |
// 面のすべての頂点を削除 | |
this.clear = function() { | |
this.point = []; | |
this.repaint(); | |
} | |
// 初期化時の描画 | |
this.repaint(); | |
} | |
// 3次元座標系 | |
var ThreeDim = function(canvas) { | |
this.width = canvas.width; | |
this.height = canvas.height; | |
this.ctx = canvas.getContext('2d'); | |
// 中心Oの座標(スクリーン座標) | |
this.oX = Math.ceil(this.width / 2); | |
this.oY = Math.ceil(this.height / 2); | |
// 面確定フラグ(trueの時は新しい頂点を追加できない) | |
this.commit_flg = false; | |
// 頂点(Point3D)を格納する配列 | |
this.point = []; | |
// 透視投影変換に必要なパラメータ | |
this.the = 20; | |
this.phi = 60; | |
this.rho = 1000; | |
this.dis = 750; | |
// 描画処理 | |
this.repaint = function() { | |
// 一度描画をクリア | |
this.ctx.clearRect(0, 0, this.width, this.height); | |
// 座標軸を描画 | |
this.drawAxis(); | |
// 平面を描画 | |
this.drawSurface(); | |
} | |
// 座標軸を描画 | |
this.drawAxis = function() { | |
// 原点、x軸終点、y軸終点、z軸終点 | |
var ow = new Point3D(0, 0, 0); | |
var xw = new Point3D(200, 0, 0); | |
var yw = new Point3D(0, 200, 0); | |
var zw = new Point3D(0, 0, 200); | |
// 透視投影変換によって各点の2次元座標を求める | |
var os = ow.toScreenAxis(this.the, this.phi, this.rho, this.dis); | |
var xs = xw.toScreenAxis(this.the, this.phi, this.rho, this.dis); | |
var ys = yw.toScreenAxis(this.the, this.phi, this.rho, this.dis); | |
var zs = zw.toScreenAxis(this.the, this.phi, this.rho, this.dis); | |
// 座標軸を描画 | |
this.drawLine(os, xs); | |
this.drawLine(os, ys); | |
this.drawLine(os, zs); | |
// 原点を表す文字「O」を描画 | |
this.drawText(os, 'O'); | |
this.drawText(xs, 'X'); | |
this.drawText(ys, 'Y'); | |
this.drawText(zs, 'Z'); | |
} | |
// 平面を描画 | |
this.drawSurface = function() { | |
var length = this.point.length; | |
if (length === 0) return; | |
for (var i = 0; i < length; i++) { | |
// 頂点の描画 | |
this.drawPoint3D(this.point[i], '#000'); | |
if (i != 0) { | |
// 線分の描画 | |
this.drawLine3D(this.point[i - 1], this.point[i], '#000', 3); | |
} | |
} | |
// 多角形を作れる場合は、最初と最後の頂点を結ぶ線分を描画 | |
if (length >= 3) { | |
if (this.commit_flg) { | |
// 面が確定している場合 | |
this.drawLine3D(this.point[length - 1], this.point[0], '#000', 3); | |
} else { | |
// 面が未確定の場合 | |
this.drawLine3D(this.point[length - 1], this.point[0], '#F66', 1); | |
} | |
} | |
} | |
// 3次元座標から2次元座標を計算して頂点を描画 | |
this.drawPoint3D = function(point3D, color) { | |
// 透視投影変換によって頂点の2次元座標を求める | |
var point2D = point3D.toScreenAxis(this.the, this.phi, this.rho, this.dis); | |
this.ctx.fillStyle = color; | |
// 指定位置を中心に円を描画 | |
this.ctx.beginPath(); | |
var screenX = point2D.x + this.oX; | |
var screenY = this.oY - point2D.y; | |
this.ctx.arc(screenX, screenY, 5, 0, Math.PI * 2, false); | |
this.ctx.fill(); | |
} | |
// 3次元座標から2次元座標を計算して線分を描画 | |
this.drawLine3D = function(startPoint3D, endPoint3D, color, lineWidth) { | |
// 透視投影変換によって始点と終点の2次元座標を求める | |
var startPoint2D = startPoint3D.toScreenAxis(this.the, this.phi, this.rho, this.dis); | |
var endPoint2D = endPoint3D.toScreenAxis(this.the, this.phi, this.rho, this.dis); | |
// 始点と終点を結ぶ線分を描画 | |
this.drawLine(startPoint2D, endPoint2D, color, lineWidth); | |
} | |
// 指定された2つの点を結ぶ線分を描画 | |
this.drawLine = function(startPoint2D, endPoint2D, color, lineWidth) { | |
this.ctx.strokeStyle = (color === undefined) ? '#999' : color; | |
this.ctx.lineWidth = (lineWidth === undefined) ? 1 : lineWidth; | |
// 線分を描画 | |
this.ctx.beginPath(); | |
this.ctx.moveTo(startPoint2D.x + this.oX, -(startPoint2D.y - this.oY)); | |
this.ctx.lineTo(endPoint2D.x + this.oX, -(endPoint2D.y - this.oY)); | |
this.ctx.stroke(); | |
} | |
// 指定された位置に文字を描画 | |
this.drawText = function(point2D, text) { | |
this.ctx.beginPath(); | |
var maxWidth = 100; | |
this.ctx.font = "12px 'Verdana'"; | |
this.ctx.textAlign = 'right'; | |
this.ctx.fillText(text, point2D.x + this.oX - 5, -(point2D.y - this.oY) + 15, maxWidth); | |
} | |
// 頂点の追加 | |
this.addPoint = function(x, y, z) { | |
this.point.push(new Point3D(x, y, z)); | |
} | |
// 頂点の削除 | |
this.deletePoint = function(index) { | |
this.point.splice(index, 1); | |
} | |
// 面の描画確定 | |
this.commit = function() { | |
this.commit_flg = true; | |
this.repaint(); | |
} | |
// 面の描画確定を解除 | |
this.cancel = function() { | |
this.commit_flg = false; | |
this.repaint(); | |
} | |
// 面のすべての頂点を削除 | |
this.clear = function() { | |
this.point = []; | |
this.repaint(); | |
} | |
// 初期化時の描画 | |
this.repaint(); | |
} | |
// 3次元座標を持つ頂点 | |
var Point3D = function(x, y, z) { | |
this.x = (x === undefined) ? 0 : x; | |
this.y = (y === undefined) ? 0 : y; | |
this.z = (z === undefined) ? 0 : z; | |
// 透視投影変換による2次元座標への変換処理 | |
this.toScreenAxis = function(the, phi, rho, dis) { | |
var p = new Point2D(); | |
// 円周率、sinθ、cosθ、sinφ、cosφ | |
var pi = Math.PI; | |
var sinT = Math.sin(the * pi / 180); | |
var cosT = Math.cos(the * pi / 180); | |
var sinP = Math.sin(phi * pi / 180); | |
var cosP = Math.cos(phi * pi / 180); | |
// 変換行列 | |
var vs = [ | |
[-dis * sinT, dis * cosT, 0, 0], | |
[-dis * cosT * cosP, -dis * sinT * cosP, dis * sinP, 0], | |
[-cosT * sinP, -sinT * sinP, -cosP, rho] | |
]; | |
// 行列計算 | |
var tmpX = this.x * vs[0][0] + this.y * vs[0][1] + this.z * vs[0][2] + vs[0][3]; | |
var tmpY = this.x * vs[1][0] + this.y * vs[1][1] + this.z * vs[1][2] + vs[1][3]; | |
var tmpW = this.x * vs[2][0] + this.y * vs[2][1] + this.z * vs[2][2] + vs[2][3]; | |
// 2次元座標を計算 | |
p.x = Math.round(tmpX / tmpW); | |
p.y = Math.round(tmpY / tmpW); | |
return p; | |
} | |
} | |
// 2次元座標を持つ頂点 | |
var Point2D = function(x, y) { | |
this.x = (x === undefined) ? 0 : x; | |
this.y = (y === undefined) ? 0 : y; | |
} | |
window.onload = function() { | |
// canvas要素を取得し、サイズ設定 | |
canvas2D = document.getElementById('axis2DCanvas'); | |
canvas2D.width = 395; | |
canvas2D.height = 350; | |
canvas3D = document.getElementById('axis3DCanvas'); | |
canvas3D.width = 395; | |
canvas3D.height = 350; | |
// 2次元座標軸のオブジェクト作成 | |
twoDim = new TwoDim(canvas2D); | |
// 3次元座標軸のオブジェクト作成 | |
threeDim = new ThreeDim(canvas3D); | |
// マウス位置の座標計算(2次元座標系)(canvasの左上を基準。-2ずつしているのはborderの分) | |
function calcMouseCoordinate2D(e) { | |
var rect = e.target.getBoundingClientRect(); | |
mouseX2D = e.clientX - Math.floor(rect.left) - 2; | |
mouseY2D = e.clientY - Math.floor(rect.top) - 2; | |
} | |
// マウス位置の座標計算(3次元座標系)(canvasの左上を基準。-2ずつしているのはborderの分) | |
function calcMouseCoordinate3D(e) { | |
var rect = e.target.getBoundingClientRect(); | |
mouseX3D = e.clientX - Math.floor(rect.left) - 2; | |
mouseY3D = e.clientY - Math.floor(rect.top) - 2; | |
} | |
// 2次元座標系のmousedownイベント登録 | |
canvas2D.onmousedown = function(e) { | |
// 面が確定していれば何もしない | |
if (twoDim.commit_flg) return; | |
// マウス位置のスクリーン座標(mouseX2D, mouseY2D)を取得 | |
calcMouseCoordinate2D(e); | |
// 3次元座標を決定 | |
var x = mouseX2D - twoDim.oX; | |
var y = twoDim.oY - mouseY2D; | |
var z; | |
if (twoDim.point.length >= 3) { | |
// 平面の方程式が確定していれば、計算でz座標を求める | |
z = twoDim.calcZ(x, y); | |
} else { | |
// 平面の方程式が確定していなければ、z座標の入力プロンプトを表示 | |
z = window.prompt("z座標を入力してください。", "0"); | |
if (z === null) return; | |
} | |
// 2次元座標系、3次元座標系それぞれに頂点を追加 | |
twoDim.addPoint(x, y, z); | |
threeDim.addPoint(x, y, z); | |
// 3つ目の頂点の場合、平面の方程式を求めておく(次の頂点から自動計算) | |
if (twoDim.point.length === 3) { | |
twoDim.calcEquation(); | |
} | |
// 2次元座標系、3次元座標系それぞれを再描画 | |
twoDim.repaint(); | |
threeDim.repaint(); | |
} | |
// 2次元座標系のmousemoveイベント登録 | |
canvas2D.onmousemove = function(e) { | |
// 面が確定していれば何もしない | |
if (twoDim.commit_flg) return; | |
// 再描画(前回のイベントで描画された線分と頂点を消す) | |
twoDim.repaint(); | |
// マウス位置のスクリーン座標(mouseX2D, mouseY2D)を取得 | |
calcMouseCoordinate2D(e); | |
// マウス位置の点の描画 | |
twoDim.drawTempPoint(mouseX2D, mouseY2D); | |
// マウス位置に向けた線分の描画 | |
twoDim.drawTempLine(mouseX2D, mouseY2D); | |
} | |
// 2次元座標系のmouseoutイベント登録 | |
canvas2D.onmouseout = function(e) { | |
// 再描画(マウス移動時に描画された線分と頂点を消す) | |
twoDim.repaint(); | |
} | |
// 3次元座標系のmousedownイベント登録 | |
canvas3D.onmousedown = function(e) { | |
// マウス位置のスクリーン座標(mouseX3D, mouseY3D)を取得 | |
calcMouseCoordinate3D(e); | |
// ドラッグ開始時点のパラメータを退避しておく | |
sX3D = mouseX3D; | |
sY3D = mouseY3D; | |
sT3D = threeDim.the; | |
sP3D = threeDim.phi; | |
} | |
// 3次元座標系のmousemoveイベント登録 | |
canvas3D.onmousemove = function(e) { | |
// マウス位置のスクリーン座標(mouseX3D, mouseY3D)を取得 | |
calcMouseCoordinate3D(e); | |
if (sX3D != null && sY3D != null && sT3D != null && sP3D != null) { | |
// ドラッグ開始位置からの移動距離(x方向、y方向)を用いて、透視投影変換のパラメータを更新 | |
threeDim.the = sT3D - Math.floor((mouseX3D - sX3D) / 0.75); | |
threeDim.phi = sP3D - Math.floor((mouseY3D - sY3D) / 0.75); | |
// 再描画 | |
threeDim.repaint(); | |
} | |
} | |
// 3次元座標系のmouseupイベント登録 | |
canvas3D.onmouseup = function(e) { | |
// ドラッグ終了なのでドラッグ開始時点に退避させていたパラメータをクリア | |
sX3D = null; | |
sY3D = null; | |
sT3D = null; | |
sP3D = null; | |
} | |
// 3次元座標系のmouseoutイベント登録 | |
canvas3D.onmouseout = function(e) { | |
// マウスが3次元座標系の外に出たらドラッグ終了として、 | |
// ドラッグ開始時点に退避させていたパラメータをクリア | |
sX3D = null; | |
sY3D = null; | |
sT3D = null; | |
sP3D = null; | |
} | |
// documentオブジェクトのkeydownイベント登録 | |
document.onkeydown = function(e) { | |
var ENTER = 13; | |
var ESC = 27; | |
var DELETE = 8; | |
if (e.keyCode === ENTER && !twoDim.commit_flg) { | |
// ENTERで面を確定し、再描画 | |
twoDim.commit(); | |
threeDim.commit(); | |
} else if (e.keyCode === ESC) { | |
if (twoDim.commit_flg) { | |
// (面が確定している時にESC)面の確定を解除し、再描画 | |
twoDim.cancel(); | |
threeDim.cancel(); | |
} else { | |
// (面が確定していない時にESC)頂点をすべてクリアし、再描画 | |
if (window.confirm('頂点をすべてクリアしていいですか?')) { | |
twoDim.clear(); | |
threeDim.clear(); | |
} | |
} | |
} else if (e.keyCode === DELETE) { | |
// DELETEで最後に追加した点を削除し、再描画 | |
if (twoDim.point.length != 0 && !twoDim.commit_flg) { | |
twoDim.deletePoint(twoDim.point.length - 1); | |
twoDim.repaint(); | |
threeDim.deletePoint(threeDim.point.length - 1); | |
threeDim.repaint(); | |
} | |
// 本来の処理をキャンセル | |
e.preventDefault(); | |
} | |
} | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
body { | |
width: 960px; | |
margin: 0 auto; | |
} | |
#canvasWrapper { | |
position: relative; | |
height: 412px; | |
margin: 30px auto; | |
text-align: center; | |
overflow: hidden; | |
} | |
#axis2DCanvas { | |
position: absolute; | |
top: 30px; | |
left: 30px; | |
border: 1px solid #CCC; | |
} | |
#axis3DCanvas { | |
position: absolute; | |
top: 30px; | |
right: 30px; | |
border: 1px solid #CCC; | |
} | |
#arrowBox { | |
position: absolute; | |
top: 155px; | |
left: 455px; | |
font-size: 50px; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment