@9am/img-halftone
Version:
A web component turns <img> into halftone.
75 lines (69 loc) • 11.5 kB
JavaScript
/**
* name: @9am/img-halftone@1.0.3
* desc: A web component turns <img> into halftone.
* author: 9am <tech.9am@gmail.com> [https://9am.github.io/]
* homepage: https://github.com/9am/halftone#readme
* license: MIT
*/
(function(g,l){typeof exports=="object"&&typeof module<"u"?l(exports):typeof define=="function"&&define.amd?define(["exports"],l):(g=typeof globalThis<"u"?globalThis:g||self,l(g.ImgHalftone={}))})(this,function(g){"use strict";var $=Object.defineProperty;var O=(g,l,h)=>l in g?$(g,l,{enumerable:!0,configurable:!0,writable:!0,value:h}):g[l]=h;var c=(g,l,h)=>(O(g,typeof l!="symbol"?l+"":l,h),h);const l=`:host{display:inline-block;position:relative;font-size:0;overflow:hidden;width:100%}:host *{box-sizing:border-box}:root{width:100%}.painter{width:100%}.grid-painter{--r: 50%;position:relative;width:100%;aspect-ratio:var(--ratio);isolation:isolate}.grid-painter .layer{mix-blend-mode:multiply;position:absolute;top:0;left:0;width:100%;height:100%;display:grid;grid-template-columns:repeat(var(--column),1fr);grid-template-rows:repeat(var(--row),1fr);transform:rotate3d(0,0,1,calc(var(--rad) * 1rad)) scale(var(--scale-x),var(--scale-y))}.grid-painter .layer .cell{background:var(--color);border-radius:100%;transform:scale(calc(var(--size) * 1.5));clip-path:polygon(calc(var(--r) + var(--r) * cos(0)) calc(var(--r) + var(--r) * sin(0)),calc(var(--r) + var(--r) * cos(var(--sl))) calc(var(--r) + var(--r) * sin(var(--sl))),calc(var(--r) + var(--r) * cos(var(--sl) * 2)) calc(var(--r) + var(--r) * sin(var(--sl) * 2)),calc(var(--r) + var(--r) * cos(var(--sl) * 3)) calc(var(--r) + var(--r) * sin(var(--sl) * 3)),calc(var(--r) + var(--r) * cos(var(--sl) * 4)) calc(var(--r) + var(--r) * sin(var(--sl) * 4)),calc(var(--r) + var(--r) * cos(var(--sl) * 5)) calc(var(--r) + var(--r) * sin(var(--sl) * 5)))}.grid-painter.triangle .cell{border-radius:0;--sl: 1turn / 3}.grid-painter.rectangle .cell{border-radius:0;--sl: 1turn / 4}.grid-painter.hexagon .cell{border-radius:0;--sl: 1turn / 6}.grid-painter.char .cell{color:var(--color);background:unset;border-radius:0;font:900 calc(100cqh / var(--row)) monospace;position:relative}.grid-painter.char .cell:before{position:absolute;content:var(--char)}#img{position:absolute;top:0;left:0;z-index:1;width:100%;visibility:hidden}
`;var h=(d=>(d.CIRCLE="circle",d.TRIANGLE="triangle",d.RECTANGLE="rectangle",d.HEXAGON="hexagon",d.CHAR="char",d))(h||{});const v=window.devicePixelRatio||1,b=(d,t,e,s,n)=>{let r=0;for(;r<d;){const i=r===0?t.moveTo:t.lineTo,a=r*Math.PI*2/d;i.call(t,e+n*Math.cos(a),s+n*Math.sin(a)),r++}},y=class y{constructor({shape:t}){c(this,"dom");c(this,"ctx");c(this,"shape");this.dom=document.createElement("canvas"),this.dom.classList.add("painter","canvas-painter"),this.ctx=this.dom.getContext("2d",{antialias:!1}),this.shape=y.shapeMap.get(t)}draw(t,e){const[s,n]=e;this.dom.width=Math.floor(s*v),this.dom.height=Math.floor(n*v),t.forEach(r=>{this.ctx.globalCompositeOperation="multiply",this.ctx.imageSmoothingEnabled=!1,this.ctx.scale(v,v),this.ctx.rotate(-r.angle),this.ctx.translate(-n*Math.sin(r.angle),0),this.ctx.fillStyle=r.color;const[i,a]=r.size,[o,u]=r.cellSize,w=new Path2D;r.cells.forEach((M,m)=>{const[I,S]=[m%i,Math.floor(m/i)],[j,W]=[I*o+o*.5,S*u+u*.5];this.shape(w,j,W,o,u,M)}),this.ctx.fill(w),this.ctx.resetTransform()})}};c(y,"shapeMap",new Map([[h.CIRCLE,(t,e,s,n,r,i)=>{const a=i*n*.7;t.moveTo(e,s),t.arc(e,s,a,0,Math.PI*2)}],[h.TRIANGLE,(t,e,s,n,r,i)=>{b(3,t,e,s,i*n*.7)}],[h.RECTANGLE,(t,e,s,n,r,i)=>{b(4,t,e,s,i*n*.7)}],[h.HEXAGON,(t,e,s,n,r,i)=>{b(6,t,e,s,i*n*.7)}]]));let k=y;const _=class _{constructor({shape:t}){c(this,"dom");this.dom=document.createElement("div"),this.dom.classList.add("painter","grid-painter",t)}createLayer(t,e,s){const[n,r]=t.size,[i,a]=t.viewBox,o=document.createElement("section");o.classList.add("layer",t.name),o.style.setProperty("--rad",`${-t.angle}`),o.style.setProperty("--color",`${t.color}`),o.style.setProperty("--column",`${n}`),o.style.setProperty("--row",`${r}`),o.style.setProperty("--scale-x",`${i/e}`),o.style.setProperty("--scale-y",`${a/s}`);const u=t.cells.reduce((w,M)=>{const m=document.createElement("div");return m.classList.add("cell"),m.style.setProperty("--size",`${M}`),m.style.setProperty("--char",`'${_.randomChar()}'`),w.append(m),w},document.createDocumentFragment());return o.append(u),o}draw(t,e){const[s,n]=e;this.dom.innerHTML="",this.dom.style.setProperty("--ratio",`${s} / ${n}`);const r=t.reduce((i,a)=>{const o=this.createLayer(a,s,n);return i.append(o),i},document.createDocumentFragment());this.dom.append(r)}};c(_,"randomChar",()=>{const t=Math.floor(Math.random()*99+30);return String.fromCharCode(t)});let x=_;class C{constructor(t){this._waitForWorker=t||(()=>new Promise(e=>{this.addWorker=e})),this.run=this.run.bind(this)}async run(t,e){return this._waitForWorker().then(s=>new Promise((n,r)=>{s.onmessage=i=>{n(i.data),e(s)},s.onerror=i=>{r(i),e(s)},s.postMessage(t)}))}}class E{constructor({worker:t=()=>({}),size:e=1}){this._worker=t,this._size=e,this._running=0,this._workers=[],this._taskQueue=[],this._getWorker=this._getWorker.bind(this),this._freeWorker=this._freeWorker.bind(this)}addTask(t){const e=this._workers.length||this._running<this._size,s=new C(e?this._getWorker:null);return e||this._taskQueue.push(s),s.run(t,this._freeWorker)}async _getWorker(){return new Promise((t,e)=>{this._workers.length&&(this._running++,t(this._workers.pop())),this._running<this._size&&(this._running++,t(this._worker())),e(`max worker: ${this._size}`)})}_freeWorker(t){if(this._taskQueue.length){this._taskQueue.shift().addWorker(t);return}this._running--,this._workers.push(t)}}const P=async()=>{const d=await Promise.resolve().then(()=>R);return new Worker(URL.createObjectURL(new Blob([d.default],{type:"application/script"})))},A=4,L=new E({worker:P,size:window.navigator.hardwareConcurrency&&window.navigator.hardwareConcurrency>1?Math.max(1,A):1});class p{constructor(t){c(this,"_canvas");c(this,"_ctx");c(this,"_cells");c(this,"_size");c(this,"_angle");c(this,"_options");c(this,"viewBox");c(this,"color","black");this.color=t.color,this._canvas=document.createElement("canvas"),this._ctx=this._canvas.getContext("2d",{alpha:!1,willReadFrequently:!0,antialias:!1}),this._ctx.imageSmoothingEnabled=!1,this.update(t)}static deg2rad(t=0){return t*Math.PI/180}getOrigin(){const{source:t,deg:e}=this._options,[s,n]=[t.width,t.height];this._angle=p.deg2rad(e);const r=Math.cos(this.angle),i=Math.sin(this.angle),[a,o]=[Math.ceil(s*r+n*i),Math.ceil(s*i+n*r)];this._canvas.width=a,this._canvas.height=o,this.viewBox=[a,o],this._ctx.fillStyle="white",this._ctx.fillRect(0,0,a,o),this._ctx.translate(n*i,0),this._ctx.rotate(this.angle),this._ctx.drawImage(t,0,0,s,n),this._ctx.resetTransform();const{data:u}=this._ctx.getImageData(0,0,a,o);return{origin:u,vw:a,vh:o}}async update(t){if(this._options={...this._options,...t},!this._options.source)return;const{name:e,cellSize:s}=this._options,n=this.getOrigin(),{cells:r,column:i,row:a}=await L.addTask({...n,name:e,cellSize:s});this._size=[i,a],this._cells=r}get angle(){return this._angle}get size(){return this._size}get cellSize(){return this._options.cellSize}get name(){return this._options.name}get cells(){return this._cells}destory(){}}const T=Math.pow(2,21),z=document.createElement("template");z.innerHTML=`<style>${l}</style><img id="img" alt="img-halftone" />`;class f extends HTMLElement{constructor(){super();c(this,"img");c(this,"painter");c(this,"channels");this.attachShadow({mode:"open"}),this.shadowRoot.append(z.content.cloneNode(!0)),this.painter=this.varient==="grid"?new x({shape:this.shape}):new k({shape:this.shape}),this.channels=[new p({name:"key",color:"#333",deg:45}),new p({name:"cyan",color:"cyan",deg:15}),new p({name:"magenta",color:"magenta",deg:75}),new p({name:"yellow",color:"yellow",deg:0})],this.img=this.shadowRoot.querySelector("#img")}static loadImage(e=""){return new Promise((s,n)=>{let r=new Image;r.crossOrigin="anonymous",r.id="img",r.setAttribute("part","img"),r.onload=()=>{s(r)},r.onerror=i=>n(i),r.src=e})}static get observedAttributes(){return["src","alt"]}async attributeChangedCallback(e,s,n){var r;if(s!==n)switch(e){case"src":{if(!this.src)break;try{this.shadowRoot.host.classList.add("loading");const i=await f.loadImage(this.src);i.setAttribute("alt",this.alt),this.img.parentNode.replaceChild(i,this.img),this.img=i;const a=this.img.cloneNode(),o=a.width*a.height,u=Math.sqrt(T/o);a.width=Math.ceil(a.width*u),a.height=Math.ceil(a.height*u),await this.update({source:a})}finally{this.shadowRoot.host.classList.remove("loading")}break}case"alt":{(r=this.img)==null||r.setAttribute("alt",this.alt);break}}}async update({source:e}){const s=this.cellsize,n=[s,s];await Promise.all(this.channels.map(r=>r.update({source:e,cellSize:n}))),this.painter.draw(this.channels,[e.width,e.height])}connectedCallback(){this.shadowRoot.appendChild(this.painter.dom),this.src||(this.src="")}disconnectedCallback(){this.img=null}get src(){return this.getAttribute("src")??""}set src(e){this.setAttribute("src",e)}get alt(){return this.getAttribute("alt")??"img-halftone"}set alt(e){this.setAttribute("alt",e)}get varient(){return this.getAttribute("varient")??"canvas"}get cellsize(){return+this.getAttribute("cellsize")||4}get shape(){const e=this.getAttribute("shape");return Object.values(h).includes(e)?e:h.CIRCLE}}window.customElements.get("img-halftone")||window.customElements.define("img-halftone",f);const R=Object.freeze(Object.defineProperty({__proto__:null,default:`const color = new Map([
['key', ({ k }) => k],
['cyan', ({ k, r }) => (1 - r - k) / (1 - k)],
['magenta', ({ k, g }) => (1 - g - k) / (1 - k)],
['yellow', ({ k, b }) => (1 - b - k) / (1 - k)],
]);
const toFixed = (num, fact = 1000) => {
return Math.trunc(num * fact) / fact;
};
const getAvg = (data) => {
const len = data.length;
let sum = 0;
for (let i = 0; i < len; i += 1) {
sum += data[i];
}
return sum / len;
};
const getImageData = (input, w, h, sx, sy, sw, sh) => {
const data = [];
for (let j = 0; j < sh; j++) {
for (let i = 0; i < sw; i++) {
const x = sx + i;
const y = sy + j;
const val = input[y * w + x];
if (val !== undefined) {
data.push(val);
}
}
}
return data;
};
const getCells = ({ origin, vw, vh, cellSize, name }) => {
const [cw, ch] = cellSize;
const colorPicker = color.get(name);
const next = [];
// convert channel color
const len = origin.length;
for (let i = 0; i < len; i += 4) {
const [r, g, b] = [origin[i] / 255, origin[i + 1] / 255, origin[i + 2] / 255];
const k = 1 - Math.max(r, g, b);
let val = colorPicker({ k, r, g, b });
next.push(val);
}
// return cells
const column = Math.ceil(vw / cw);
const row = Math.ceil(vh / ch);
const cells = [];
for (let j = 0; j < row; j++) {
for (let i = 0; i < column; i++) {
const cell = getImageData(next, vw, vh, i * cw, j * ch, cw, ch);
const avg = getAvg(cell, 1);
const size = toFixed(avg);
cells.push(size);
}
}
return { cells, column, row };
};
self.onmessage = (evt) => {
postMessage(getCells(evt.data));
};
`},Symbol.toStringTag,{value:"Module"}));g.default=f,Object.defineProperties(g,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});