Created
December 18, 2019 01:31
-
-
Save jseppi/a9b523bc00f31eafad86140518bf1ed3 to your computer and use it in GitHub Desktop.
Render font previews // source https://jsbin.com/lomovel
This file contains hidden or 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
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8" /> | |
<title>Render font previews</title> | |
<meta | |
name="viewport" | |
content="initial-scale=1,maximum-scale=1,user-scalable=no" | |
/> | |
<script src="https://api.tiles.mapbox.com/mapbox-gl-js/v1.5.0/mapbox-gl.js"></script> | |
<link | |
href="https://api.tiles.mapbox.com/mapbox-gl-js/v1.5.0/mapbox-gl.css" | |
rel="stylesheet" | |
/> | |
<link | |
href="https://api.mapbox.com/mapbox-assembly/v0.24.0/assembly.min.css" | |
rel="stylesheet" | |
/> | |
<script | |
async | |
defer | |
src="https://api.mapbox.com/mapbox-assembly/v0.24.0/assembly.js" | |
></script> | |
<style> | |
body { | |
margin: 0; | |
padding: 0; | |
background: #3e3e3e; | |
} | |
.preview-image { | |
border: 1px solid yellow; | |
display:block; | |
margin-top: 10px; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="render_map_1" style="position:absolute;top:-200px;width:1200px; height: 120px; visibility: hidden;" class="border"></div> | |
<script id="jsbin-javascript"> | |
"use strict"; | |
function _toArray(arr) { return Array.isArray(arr) ? arr : Array.from(arr); } | |
mapboxgl.accessToken = "pk.eyJ1IjoianNlcHBpbWJ4IiwiYSI6ImNqbGU1ODdtMzBpZjUzcG1pMWJnaHB2aHgifQ.xGVwKUpyJ-S5iyaLq7GFLA"; | |
var owner = "jseppimbx"; | |
// These would be rendered in workspace upon application mount | |
var renderMap = new mapboxgl.Map({ | |
container: "render_map_1", | |
center: [0.525, -0.025], | |
zoom: 9, | |
pitch: 0, | |
bearing: 0, | |
fadeDuration: 0, | |
preserveDrawingBuffer: true, | |
localIdeographFontFamily: false | |
}); | |
var renderMapBusy = false; | |
var renderCanvas = document.createElement('canvas'); | |
var renderCtx = renderCanvas.getContext('2d'); | |
// ----- | |
function generateFontPreviewStyle(_ref) { | |
var owner = _ref.owner; | |
var name = _ref.name; | |
var text = _ref.text; | |
var color = _ref.color; | |
return { | |
version: 8, | |
glyphs: "mapbox://fonts/" + owner + "/{fontstack}/{range}.pbf", | |
sources: { | |
font: { | |
type: "geojson", | |
data: { | |
type: "FeatureCollection", | |
features: [{ | |
type: "Feature", | |
geometry: { | |
type: "Point", | |
coordinates: [0, 0] | |
}, | |
properties: {} | |
}] | |
} | |
} | |
}, | |
layers: [{ | |
id: "preview", | |
source: "font", | |
type: "symbol", | |
layout: { | |
"text-justify": "left", | |
"text-anchor": "left", | |
"text-field": text, | |
"text-font": [name], | |
"text-max-width": 800, | |
"text-size": 44 | |
}, | |
paint: { | |
"text-color": color, | |
"text-halo-color": color, | |
"text-halo-width": 0.3, | |
"text-halo-blur": 0 | |
} | |
}] | |
}; | |
} | |
// ***** redux-like stuff | |
var state = {}; | |
state.fontPreviewsToRender = new Set(); | |
state.renderedFontPreviews = {}; | |
function makeFontKey(name, color, text) { | |
return name + "^" + color + "^" + text; | |
} | |
function fontKeyToParams(key) { | |
var parts = key.split("^"); | |
return { | |
name: parts[0], | |
color: parts[1], | |
text: parts[2] | |
}; | |
} | |
function addFontPreviewToRender(name, color, text) { | |
var key = makeFontKey(name, color, text); | |
if (!state.fontPreviewsToRender.has(key)) { | |
state.fontPreviewsToRender.add(key); | |
} | |
} | |
function getFontPreviewsToRender() { | |
var paramsToRender = []; | |
state.fontPreviewsToRender.forEach(function (key) { | |
var params = fontKeyToParams(key); | |
paramsToRender.push(params); | |
}); | |
return paramsToRender; | |
} | |
function saveRenderedFontPreviewData(name, color, text, dataURL) { | |
var key = makeFontKey(name, color, text); | |
state.renderedFontPreviews[key] = dataURL; | |
if (state.fontPreviewsToRender.has(key)) { | |
state.fontPreviewsToRender["delete"](key); | |
} | |
} | |
// ***** fake output helpers | |
function addImage(name, color, text) { | |
var img = document.createElement("img"); | |
var key = makeFontKey(name, color, text); | |
var dataURL = state.renderedFontPreviews[key]; | |
img.src = dataURL; | |
img.classList.add("preview-image"); | |
document.body.appendChild(img); | |
} | |
// ------------------- | |
function trimCanvas(canvas) { | |
// From https://stackoverflow.com/a/58882518 | |
var context = canvas.getContext("2d"); | |
var topLeft = { | |
x: canvas.width, | |
y: canvas.height, | |
update: function update(x, y) { | |
this.x = Math.min(this.x, x); | |
this.y = Math.min(this.y, y); | |
} | |
}; | |
var bottomRight = { | |
x: 0, | |
y: 0, | |
update: function update(x, y) { | |
this.x = Math.max(this.x, x); | |
this.y = Math.max(this.y, y); | |
} | |
}; | |
var imageData = context.getImageData(0, 0, canvas.width, canvas.height); | |
for (var x = 0; x < canvas.width; x++) { | |
for (var y = 0; y < canvas.height; y++) { | |
var alpha = imageData.data[y * (canvas.width * 4) + x * 4 + 3]; | |
if (alpha !== 0) { | |
topLeft.update(x, y); | |
bottomRight.update(x, y); | |
} | |
} | |
} | |
var width = bottomRight.x - topLeft.x; | |
var height = bottomRight.y - topLeft.y; | |
var croppedCanvas = context.getImageData(topLeft.x, topLeft.y, width, height); | |
canvas.width = width; | |
canvas.height = height; | |
context.putImageData(croppedCanvas, 0, 0); | |
return canvas; | |
} | |
function trimWebglCanvas(webglCanvas, callback) { | |
renderCanvas.width = webglCanvas.width; | |
renderCanvas.height = webglCanvas.height; | |
var img = new Image(); | |
img.onload = function () { | |
renderCtx.drawImage(img, 0, 0); | |
var trimmed = trimCanvas(renderCanvas); | |
callback(trimmed); | |
}; | |
img.src = webglCanvas.toDataURL(); | |
} | |
// ***** render component stuff | |
var demoFontsToRender = ["Komika Hand Bold Italic", "Komika Hand Bold", "Komika Hand Italic", "Komika Hand Regular", "Komika Parch Regular", "Komika Title - Axis Regular", "Komika Title - Kaps Regular", "Komika Title - Paint Regular", "Komika Title - Wide Regular", "Komika Title Regular"]; | |
var color = "#fff"; | |
demoFontsToRender.forEach(function (name) { | |
addFontPreviewToRender(name, color, name); | |
}); | |
// get initial list of job params | |
var initialJobParams = getFontPreviewsToRender(); | |
var i = 0; | |
var windowMethod = "requestAnimationFrame"; | |
function processRenderJobs(jobParams) { | |
if (i > 100) { | |
console.log("emergency brake"); | |
return; | |
} | |
if (renderMapBusy) { | |
// requeue | |
window[windowMethod](function () { | |
return processRenderJobs(jobParams); | |
}); | |
return; | |
} | |
// else | |
var jobStart = Date.now(); | |
renderMapBusy = true; | |
var _jobParams = _toArray(jobParams); | |
var params = _jobParams[0]; | |
var remainingParams = _jobParams.slice(1); | |
var style = generateFontPreviewStyle({ | |
owner: owner, | |
name: params.name, | |
text: params.text, | |
color: params.color | |
}); | |
renderMap.setStyle(style); | |
renderMap.once("idle", function () { | |
trimWebglCanvas(renderMap.getCanvas(), function (cropped) { | |
var dataURL = cropped.toDataURL(); | |
// Save the rendered image data into state | |
saveRenderedFontPreviewData(params.name, params.color, params.text, dataURL); | |
renderMapBusy = false; | |
console.log(params.name + ": " + (Date.now() - jobStart) + "ms"); | |
// TODO: instead of adding the image here, we'd i guess have | |
// the consuming components do something when store state is altered | |
// (via selector) due to saveRenderedFontPreviewData | |
addImage(params.name, params.color, params.text); | |
// we have more to process, so queue up the remaining | |
if (remainingParams.length) { | |
window[windowMethod](function () { | |
return processRenderJobs(remainingParams); | |
}); | |
} else { | |
console.log("Total time: " + (Date.now() - start) + "ms"); | |
} | |
}); | |
}); | |
} | |
var start = Date.now(); | |
window[windowMethod](function () { | |
return processRenderJobs(initialJobParams); | |
}); | |
</script> | |
<script id="jsbin-source-javascript" type="text/javascript">mapboxgl.accessToken = | |
"pk.eyJ1IjoianNlcHBpbWJ4IiwiYSI6ImNqbGU1ODdtMzBpZjUzcG1pMWJnaHB2aHgifQ.xGVwKUpyJ-S5iyaLq7GFLA"; | |
const owner = "jseppimbx"; | |
// These would be rendered in workspace upon application mount | |
const renderMap = new mapboxgl.Map({ | |
container: "render_map_1", | |
center: [0.525, -0.025], | |
zoom: 9, | |
pitch: 0, | |
bearing: 0, | |
fadeDuration: 0, | |
preserveDrawingBuffer: true, | |
localIdeographFontFamily: false | |
}); | |
let renderMapBusy = false; | |
const renderCanvas = document.createElement('canvas'); | |
const renderCtx = renderCanvas.getContext('2d'); | |
// ----- | |
function generateFontPreviewStyle({ owner, name, text, color }) { | |
return { | |
version: 8, | |
glyphs: `mapbox://fonts/${owner}/{fontstack}/{range}.pbf`, | |
sources: { | |
font: { | |
type: "geojson", | |
data: { | |
type: "FeatureCollection", | |
features: [ | |
{ | |
type: "Feature", | |
geometry: { | |
type: "Point", | |
coordinates: [0, 0] | |
}, | |
properties: {} | |
} | |
] | |
} | |
} | |
}, | |
layers: [ | |
{ | |
id: "preview", | |
source: "font", | |
type: "symbol", | |
layout: { | |
"text-justify": "left", | |
"text-anchor": "left", | |
"text-field": text, | |
"text-font": [name], | |
"text-max-width": 800, | |
"text-size": 44 | |
}, | |
paint: { | |
"text-color": color, | |
"text-halo-color": color, | |
"text-halo-width": 0.3, | |
"text-halo-blur": 0 | |
} | |
} | |
] | |
}; | |
} | |
// ***** redux-like stuff | |
const state = {}; | |
state.fontPreviewsToRender = new Set(); | |
state.renderedFontPreviews = {}; | |
function makeFontKey(name, color, text) { | |
return `${name}^${color}^${text}`; | |
} | |
function fontKeyToParams(key) { | |
const parts = key.split("^"); | |
return { | |
name: parts[0], | |
color: parts[1], | |
text: parts[2] | |
}; | |
} | |
function addFontPreviewToRender(name, color, text) { | |
const key = makeFontKey(name, color, text); | |
if (!state.fontPreviewsToRender.has(key)) { | |
state.fontPreviewsToRender.add(key); | |
} | |
} | |
function getFontPreviewsToRender() { | |
const paramsToRender = []; | |
state.fontPreviewsToRender.forEach(key => { | |
const params = fontKeyToParams(key); | |
paramsToRender.push(params); | |
}); | |
return paramsToRender; | |
} | |
function saveRenderedFontPreviewData(name, color, text, dataURL) { | |
const key = makeFontKey(name, color, text); | |
state.renderedFontPreviews[key] = dataURL; | |
if (state.fontPreviewsToRender.has(key)) { | |
state.fontPreviewsToRender.delete(key); | |
} | |
} | |
// ***** fake output helpers | |
function addImage(name, color, text) { | |
const img = document.createElement("img"); | |
const key = makeFontKey(name, color, text); | |
const dataURL = state.renderedFontPreviews[key] | |
img.src = dataURL; | |
img.classList.add("preview-image"); | |
document.body.appendChild(img); | |
} | |
// ------------------- | |
function trimCanvas(canvas) { | |
// From https://stackoverflow.com/a/58882518 | |
const context = canvas.getContext("2d"); | |
const topLeft = { | |
x: canvas.width, | |
y: canvas.height, | |
update(x, y) { | |
this.x = Math.min(this.x, x); | |
this.y = Math.min(this.y, y); | |
} | |
}; | |
const bottomRight = { | |
x: 0, | |
y: 0, | |
update(x, y) { | |
this.x = Math.max(this.x, x); | |
this.y = Math.max(this.y, y); | |
} | |
}; | |
const imageData = context.getImageData(0, 0, canvas.width, canvas.height); | |
for (let x = 0; x < canvas.width; x++) { | |
for (let y = 0; y < canvas.height; y++) { | |
const alpha = imageData.data[y * (canvas.width * 4) + x * 4 + 3]; | |
if (alpha !== 0) { | |
topLeft.update(x, y); | |
bottomRight.update(x, y); | |
} | |
} | |
} | |
const width = bottomRight.x - topLeft.x; | |
const height = bottomRight.y - topLeft.y; | |
const croppedCanvas = context.getImageData( | |
topLeft.x, | |
topLeft.y, | |
width, | |
height | |
); | |
canvas.width = width; | |
canvas.height = height; | |
context.putImageData(croppedCanvas, 0, 0); | |
return canvas; | |
} | |
function trimWebglCanvas(webglCanvas, callback) { | |
renderCanvas.width = webglCanvas.width; | |
renderCanvas.height = webglCanvas.height; | |
const img = new Image(); | |
img.onload = () => { | |
renderCtx.drawImage(img, 0, 0); | |
const trimmed = trimCanvas(renderCanvas); | |
callback(trimmed); | |
}; | |
img.src = webglCanvas.toDataURL(); | |
} | |
// ***** render component stuff | |
const demoFontsToRender = [ | |
"Komika Hand Bold Italic", | |
"Komika Hand Bold", | |
"Komika Hand Italic", | |
"Komika Hand Regular", | |
"Komika Parch Regular", | |
"Komika Title - Axis Regular", | |
"Komika Title - Kaps Regular", | |
"Komika Title - Paint Regular", | |
"Komika Title - Wide Regular", | |
"Komika Title Regular" | |
]; | |
const color = "#fff"; | |
demoFontsToRender.forEach(name => { | |
addFontPreviewToRender(name, color, name); | |
}); | |
// get initial list of job params | |
const initialJobParams = getFontPreviewsToRender(); | |
let i = 0; | |
const windowMethod = "requestAnimationFrame"; | |
function processRenderJobs(jobParams) { | |
if (i > 100) { | |
console.log("emergency brake"); | |
return; | |
} | |
if (renderMapBusy) { | |
// requeue | |
window[windowMethod](() => processRenderJobs(jobParams)); | |
return; | |
} | |
// else | |
const jobStart = Date.now(); | |
renderMapBusy = true; | |
const [params, ...remainingParams] = jobParams; | |
const style = generateFontPreviewStyle({ | |
owner, | |
name: params.name, | |
text: params.text, | |
color: params.color | |
}); | |
renderMap.setStyle(style); | |
renderMap.once("idle", () => { | |
trimWebglCanvas(renderMap.getCanvas(), cropped => { | |
const dataURL = cropped.toDataURL(); | |
// Save the rendered image data into state | |
saveRenderedFontPreviewData( | |
params.name, | |
params.color, | |
params.text, | |
dataURL | |
); | |
renderMapBusy = false; | |
console.log(`${params.name}: ${Date.now() - jobStart}ms`); | |
// TODO: instead of adding the image here, we'd i guess have | |
// the consuming components do something when store state is altered | |
// (via selector) due to saveRenderedFontPreviewData | |
addImage(params.name, params.color, params.text) | |
// we have more to process, so queue up the remaining | |
if (remainingParams.length) { | |
window[windowMethod](() => processRenderJobs(remainingParams)); | |
} else { | |
console.log(`Total time: ${Date.now() - start}ms`); | |
} | |
}); | |
}); | |
} | |
const start = Date.now(); | |
window[windowMethod](() => processRenderJobs(initialJobParams)); | |
</script></body> | |
</html> |
This file contains hidden or 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
"use strict"; | |
function _toArray(arr) { return Array.isArray(arr) ? arr : Array.from(arr); } | |
mapboxgl.accessToken = "pk.eyJ1IjoianNlcHBpbWJ4IiwiYSI6ImNqbGU1ODdtMzBpZjUzcG1pMWJnaHB2aHgifQ.xGVwKUpyJ-S5iyaLq7GFLA"; | |
var owner = "jseppimbx"; | |
// These would be rendered in workspace upon application mount | |
var renderMap = new mapboxgl.Map({ | |
container: "render_map_1", | |
center: [0.525, -0.025], | |
zoom: 9, | |
pitch: 0, | |
bearing: 0, | |
fadeDuration: 0, | |
preserveDrawingBuffer: true, | |
localIdeographFontFamily: false | |
}); | |
var renderMapBusy = false; | |
var renderCanvas = document.createElement('canvas'); | |
var renderCtx = renderCanvas.getContext('2d'); | |
// ----- | |
function generateFontPreviewStyle(_ref) { | |
var owner = _ref.owner; | |
var name = _ref.name; | |
var text = _ref.text; | |
var color = _ref.color; | |
return { | |
version: 8, | |
glyphs: "mapbox://fonts/" + owner + "/{fontstack}/{range}.pbf", | |
sources: { | |
font: { | |
type: "geojson", | |
data: { | |
type: "FeatureCollection", | |
features: [{ | |
type: "Feature", | |
geometry: { | |
type: "Point", | |
coordinates: [0, 0] | |
}, | |
properties: {} | |
}] | |
} | |
} | |
}, | |
layers: [{ | |
id: "preview", | |
source: "font", | |
type: "symbol", | |
layout: { | |
"text-justify": "left", | |
"text-anchor": "left", | |
"text-field": text, | |
"text-font": [name], | |
"text-max-width": 800, | |
"text-size": 44 | |
}, | |
paint: { | |
"text-color": color, | |
"text-halo-color": color, | |
"text-halo-width": 0.3, | |
"text-halo-blur": 0 | |
} | |
}] | |
}; | |
} | |
// ***** redux-like stuff | |
var state = {}; | |
state.fontPreviewsToRender = new Set(); | |
state.renderedFontPreviews = {}; | |
function makeFontKey(name, color, text) { | |
return name + "^" + color + "^" + text; | |
} | |
function fontKeyToParams(key) { | |
var parts = key.split("^"); | |
return { | |
name: parts[0], | |
color: parts[1], | |
text: parts[2] | |
}; | |
} | |
function addFontPreviewToRender(name, color, text) { | |
var key = makeFontKey(name, color, text); | |
if (!state.fontPreviewsToRender.has(key)) { | |
state.fontPreviewsToRender.add(key); | |
} | |
} | |
function getFontPreviewsToRender() { | |
var paramsToRender = []; | |
state.fontPreviewsToRender.forEach(function (key) { | |
var params = fontKeyToParams(key); | |
paramsToRender.push(params); | |
}); | |
return paramsToRender; | |
} | |
function saveRenderedFontPreviewData(name, color, text, dataURL) { | |
var key = makeFontKey(name, color, text); | |
state.renderedFontPreviews[key] = dataURL; | |
if (state.fontPreviewsToRender.has(key)) { | |
state.fontPreviewsToRender["delete"](key); | |
} | |
} | |
// ***** fake output helpers | |
function addImage(name, color, text) { | |
var img = document.createElement("img"); | |
var key = makeFontKey(name, color, text); | |
var dataURL = state.renderedFontPreviews[key]; | |
img.src = dataURL; | |
img.classList.add("preview-image"); | |
document.body.appendChild(img); | |
} | |
// ------------------- | |
function trimCanvas(canvas) { | |
// From https://stackoverflow.com/a/58882518 | |
var context = canvas.getContext("2d"); | |
var topLeft = { | |
x: canvas.width, | |
y: canvas.height, | |
update: function update(x, y) { | |
this.x = Math.min(this.x, x); | |
this.y = Math.min(this.y, y); | |
} | |
}; | |
var bottomRight = { | |
x: 0, | |
y: 0, | |
update: function update(x, y) { | |
this.x = Math.max(this.x, x); | |
this.y = Math.max(this.y, y); | |
} | |
}; | |
var imageData = context.getImageData(0, 0, canvas.width, canvas.height); | |
for (var x = 0; x < canvas.width; x++) { | |
for (var y = 0; y < canvas.height; y++) { | |
var alpha = imageData.data[y * (canvas.width * 4) + x * 4 + 3]; | |
if (alpha !== 0) { | |
topLeft.update(x, y); | |
bottomRight.update(x, y); | |
} | |
} | |
} | |
var width = bottomRight.x - topLeft.x; | |
var height = bottomRight.y - topLeft.y; | |
var croppedCanvas = context.getImageData(topLeft.x, topLeft.y, width, height); | |
canvas.width = width; | |
canvas.height = height; | |
context.putImageData(croppedCanvas, 0, 0); | |
return canvas; | |
} | |
function trimWebglCanvas(webglCanvas, callback) { | |
renderCanvas.width = webglCanvas.width; | |
renderCanvas.height = webglCanvas.height; | |
var img = new Image(); | |
img.onload = function () { | |
renderCtx.drawImage(img, 0, 0); | |
var trimmed = trimCanvas(renderCanvas); | |
callback(trimmed); | |
}; | |
img.src = webglCanvas.toDataURL(); | |
} | |
// ***** render component stuff | |
var demoFontsToRender = ["Komika Hand Bold Italic", "Komika Hand Bold", "Komika Hand Italic", "Komika Hand Regular", "Komika Parch Regular", "Komika Title - Axis Regular", "Komika Title - Kaps Regular", "Komika Title - Paint Regular", "Komika Title - Wide Regular", "Komika Title Regular"]; | |
var color = "#fff"; | |
demoFontsToRender.forEach(function (name) { | |
addFontPreviewToRender(name, color, name); | |
}); | |
// get initial list of job params | |
var initialJobParams = getFontPreviewsToRender(); | |
var i = 0; | |
var windowMethod = "requestAnimationFrame"; | |
function processRenderJobs(jobParams) { | |
if (i > 100) { | |
console.log("emergency brake"); | |
return; | |
} | |
if (renderMapBusy) { | |
// requeue | |
window[windowMethod](function () { | |
return processRenderJobs(jobParams); | |
}); | |
return; | |
} | |
// else | |
var jobStart = Date.now(); | |
renderMapBusy = true; | |
var _jobParams = _toArray(jobParams); | |
var params = _jobParams[0]; | |
var remainingParams = _jobParams.slice(1); | |
var style = generateFontPreviewStyle({ | |
owner: owner, | |
name: params.name, | |
text: params.text, | |
color: params.color | |
}); | |
renderMap.setStyle(style); | |
renderMap.once("idle", function () { | |
trimWebglCanvas(renderMap.getCanvas(), function (cropped) { | |
var dataURL = cropped.toDataURL(); | |
// Save the rendered image data into state | |
saveRenderedFontPreviewData(params.name, params.color, params.text, dataURL); | |
renderMapBusy = false; | |
console.log(params.name + ": " + (Date.now() - jobStart) + "ms"); | |
// TODO: instead of adding the image here, we'd i guess have | |
// the consuming components do something when store state is altered | |
// (via selector) due to saveRenderedFontPreviewData | |
addImage(params.name, params.color, params.text); | |
// we have more to process, so queue up the remaining | |
if (remainingParams.length) { | |
window[windowMethod](function () { | |
return processRenderJobs(remainingParams); | |
}); | |
} else { | |
console.log("Total time: " + (Date.now() - start) + "ms"); | |
} | |
}); | |
}); | |
} | |
var start = Date.now(); | |
window[windowMethod](function () { | |
return processRenderJobs(initialJobParams); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment