{
    "id": "bhaskar/art-gallery",
    "kind": "component",
    "title": "Art Gallery",
    "summary": "Infinite WebGL grid of artwork tiles with lens distortion, drag to pan and a zoom-out while dragging.",
    "author": "Admin",
    "stars": 0,
    "url": "https://ownlyplans.com/bhaskar/art-gallery/",
    "stack": [
        "CSS",
        "HTML",
        "JavaScript"
    ],
    "tools": [
        "claude-code",
        "cursor",
        "lovable"
    ],
    "difficulty": "beginner",
    "license": "mit",
    "version": "1.0",
    "overview": "Infinite WebGL grid of artwork tiles with lens distortion, drag to pan and a zoom-out while dragging. Ported to plain HTML, CSS and JavaScript from ObsidianUI (github.com/Atharvsinh-codez/ObsidianUI), which is released under the MIT License. The original copyright notice is kept in the code.",
    "steps": [],
    "files": [],
    "install": "",
    "how_to_use": "Use the component code (html, css, js, or react) directly, or run the install command.",
    "component": {
        "category": "Backgrounds",
        "html": "<!--\n  Ported to plain HTML/CSS/JS for OwnlyPlans from ObsidianUI (https://github.com/Atharvsinh-codez/ObsidianUI)\n  MIT License — Copyright (c) 2026 ObsidianUI\n  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated\n  documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation\n  the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to\n  permit persons to whom the Software is furnished to do so, subject to the following conditions:\n  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n  THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.\n-->\n<div class=\"op-art-gallery\" role=\"img\" aria-label=\"Infinite art gallery grid. Drag to explore.\">\n  <canvas class=\"op-ag-canvas\"></canvas>\n  <div class=\"op-ag-hint\">drag to explore</div>\n</div>",
        "css": "/* Ported from ObsidianUI (MIT License, Copyright (c) 2026 ObsidianUI) */\n.op-art-gallery{position:relative;width:100%;height:520px;overflow:hidden;background:#000;cursor:grab;touch-action:none}\n.op-art-gallery:active{cursor:grabbing}\n.op-ag-canvas{position:absolute;inset:0;display:block;width:100%;height:100%}\n.op-ag-hint{pointer-events:none;position:absolute;bottom:24px;left:50%;z-index:10;transform:translateX(-50%);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.6rem;text-transform:uppercase;letter-spacing:.2em;color:rgba(255,255,255,.2)}",
        "js": "(function(){\nvar CFG={cellSize:.75,zoomLevel:1.25,lerp:.075,border:[1,1,1,.15],bg:[0,0,0,1],text:'rgba(128,128,128,1)',hover:[1,1,1,0]};\nvar ITEMS=[[\"Low Tide\",2024],[\"Paper Sun\",2023],[\"Slow Dune\",2024],[\"North Glow\",2023],[\"Quiet Ridge\",2024],[\"Amber Field\",2022],[\"Soft Haze\",2024],[\"Far Coast\",2023],[\"Mint Hills\",2024],[\"Dusk Line\",2023],[\"Pale Moon\",2022],[\"Rose Valley\",2024],[\"Clear Cold\",2024],[\"Ember Sky\",2023],[\"Fog Bank\",2023],[\"Twin Peaks\",2024],[\"Green Hour\",2023],[\"Still Lake\",2024],[\"Heat Shimmer\",2023],[\"Blue Ledge\",2024],[\"Open Plain\",2022],[\"Long Walk\",2023],[\"High Pass\",2024],[\"Night Frost\",2022],[\"Warm Drift\",2024]];\nvar PAL=[[\"#f6d6b8\",\"#e08e6d\",\"#7a4b6b\",\"#2f2440\"],[\"#cfe8ef\",\"#7fb7c9\",\"#2f6f8f\",\"#16384d\"],[\"#efe4cf\",\"#c9a26b\",\"#6e7f5b\",\"#2e3b2c\"],[\"#f3d9e4\",\"#d38fb1\",\"#8a5a9e\",\"#3a2b55\"],[\"#fde9c8\",\"#f2b05e\",\"#c4573b\",\"#4a2230\"],[\"#dde7f0\",\"#9fb3c8\",\"#5d7493\",\"#222e42\"],[\"#e6f0dc\",\"#9cc28c\",\"#4f8a63\",\"#1f3b30\"],[\"#f0e1d2\",\"#c79a84\",\"#8a6273\",\"#33263a\"]];\nfunction rnd(seed){return function(){seed=(seed*16807)%2147483647;return (seed-1)/2147483646}}\n// Draws one original landscape tile; no outside images are used.\nfunction drawTile(ctx,x,y,s,i){var r=rnd(i*97+11),p=PAL[i%PAL.length],g=ctx.createLinearGradient(0,y,0,y+s);g.addColorStop(0,p[0]);g.addColorStop(1,p[1]);ctx.fillStyle=g;ctx.fillRect(x,y,s,s);\n  ctx.globalAlpha=.85;ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(x+s*(.2+r()*.6),y+s*(.18+r()*.14),s*(.08+r()*.06),0,Math.PI*2);ctx.fill();ctx.globalAlpha=1;\n  [[.55,p[1],.08],[.68,p[2],.07],[.82,p[3],.05]].forEach(function(l){ctx.fillStyle=l[1];ctx.beginPath();ctx.moveTo(x,y+s);for(var k=0;k<=6;k++)ctx.lineTo(x+s*k/6,y+s*(l[0]+(r()*2-1)*l[2]));ctx.lineTo(x+s,y+s);ctx.closePath();ctx.fill()})}\nfunction atlas(n,isText){var a=Math.ceil(Math.sqrt(n)),T=512,c=document.createElement('canvas');c.width=c.height=a*T;var ctx=c.getContext('2d');\n  if(!isText){ctx.fillStyle='#000';ctx.fillRect(0,0,c.width,c.height)}\n  for(var i=0;i<n;i++){var x=(i%a)*T,y=Math.floor(i/a)*T;\n    if(isText){var t=document.createElement('canvas');t.width=2048;t.height=256;var tc=t.getContext('2d');tc.font='80px monospace';tc.fillStyle=CFG.text;tc.textBaseline='middle';tc.textAlign='left';tc.fillText(ITEMS[i][0].toUpperCase(),30,128);tc.textAlign='right';tc.fillText(String(ITEMS[i][1]),2018,128);ctx.drawImage(t,x,y,T,T)}\n    else drawTile(ctx,x,y,T,i)}\n  return c}\nvar VS='attribute vec2 p;varying vec2 vUv;void main(){vUv=p*.5+.5;gl_Position=vec4(p,0.,1.);}';\nvar FS='precision highp float;uniform vec2 uOffset;uniform vec2 uResolution;uniform vec4 uBorderColor;uniform vec4 uHoverColor;uniform vec4 uBackgroundColor;uniform vec2 uMousePos;uniform float uZoom;uniform float uCellSize;uniform float uTextureCount;uniform sampler2D uImageAtlas;uniform sampler2D uTextAtlas;varying vec2 vUv;'+\n'void main(){vec2 screenUV=(vUv-.5)*2.;float radius=length(screenUV);float distortion=1.-.08*radius*radius;vec2 distortedUV=screenUV*distortion;vec2 aspectRatio=vec2(uResolution.x/uResolution.y,1.);vec2 worldCoord=distortedUV*aspectRatio;worldCoord*=uZoom;worldCoord+=uOffset;vec2 cellPos=worldCoord/uCellSize;vec2 cellId=floor(cellPos);vec2 cellUV=fract(cellPos);'+\n'vec2 m=(uMousePos/uResolution)*2.-1.;m.y=-m.y;float mr=length(m);vec2 mw=m*(1.-.08*mr*mr)*aspectRatio*uZoom+uOffset;vec2 mcell=floor(mw/uCellSize);float cd=length((cellId+.5)-(mcell+.5));float hi=1.-smoothstep(.4,.7,cd);bool hov=hi>0.&&uMousePos.x>=0.;vec3 bg=uBackgroundColor.rgb;if(hov)bg=mix(bg,uHoverColor.rgb,hi*uHoverColor.a);'+\n'float lw=.005;float gm=smoothstep(0.,lw,cellUV.x)*smoothstep(0.,lw,1.-cellUV.x)*smoothstep(0.,lw,cellUV.y)*smoothstep(0.,lw,1.-cellUV.y);float is=.6;float ib=(1.-is)*.5;vec2 iuv=(cellUV-ib)/is;float es=.01;vec2 imk=smoothstep(-es,es,iuv)*smoothstep(-es,es,1.-iuv);float ia=imk.x*imk.y;bool inI=iuv.x>=0.&&iuv.x<=1.&&iuv.y>=0.&&iuv.y<=1.;'+\n'float th=.08;float ty=.88;bool inT=cellUV.x>=.05&&cellUV.x<=.95&&cellUV.y>=ty&&cellUV.y<=(ty+th);float ti=mod(cellId.x+cellId.y*3.,uTextureCount);vec3 color=bg;float as=ceil(sqrt(uTextureCount));vec2 ap=vec2(mod(ti,as),floor(ti/as));'+\n'if(inI&&ia>0.){vec2 auv=(ap+iuv)/as;auv.y=1.-auv.y;color=mix(color,texture2D(uImageAtlas,auv).rgb,ia);}'+\n'if(inT){vec2 tcd=vec2((cellUV.x-.05)/.9,(cellUV.y-ty)/th);tcd.y=1.-tcd.y;vec4 tc=texture2D(uTextAtlas,(ap+tcd)/as);color=mix(bg,tc.rgb,tc.a);}'+\n'color=mix(color,uBorderColor.rgb,(1.-gm)*uBorderColor.a);float fade=1.-smoothstep(1.2,1.8,radius);gl_FragColor=vec4(color*fade,1.);}';\ndocument.querySelectorAll('.op-art-gallery').forEach(function(root){\n  var cv=root.querySelector('canvas'),gl=cv.getContext('webgl',{antialias:true});if(!gl)return;\n  function sh(t,s){var o=gl.createShader(t);gl.shaderSource(o,s);gl.compileShader(o);return o}\n  var pr=gl.createProgram();gl.attachShader(pr,sh(gl.VERTEX_SHADER,VS));gl.attachShader(pr,sh(gl.FRAGMENT_SHADER,FS));gl.linkProgram(pr);gl.useProgram(pr);\n  var b=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,b);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);var loc=gl.getAttribLocation(pr,'p');gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);\n  function U(n){return gl.getUniformLocation(pr,n)}\n  // Atlases are drawn top-down in canvas space; the shader expects the flipY=false layout the original used.\n  function tex(unit,canvas,nearest){var t=gl.createTexture();gl.activeTexture(gl.TEXTURE0+unit);gl.bindTexture(gl.TEXTURE_2D,t);gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false);gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,canvas);\n    var f=nearest?gl.NEAREST:gl.LINEAR;gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,f);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,f);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE)}\n  var n=ITEMS.length;tex(0,atlas(n,false),false);tex(1,atlas(n,true),false);\n  gl.uniform1i(U('uImageAtlas'),0);gl.uniform1i(U('uTextAtlas'),1);gl.uniform1f(U('uCellSize'),CFG.cellSize);gl.uniform1f(U('uTextureCount'),n);\n  gl.uniform4fv(U('uBorderColor'),CFG.border);gl.uniform4fv(U('uHoverColor'),CFG.hover);gl.uniform4fv(U('uBackgroundColor'),CFG.bg);gl.uniform2f(U('uMousePos'),-1,-1);\n  var reduce=matchMedia('(prefers-reduced-motion: reduce)').matches,lerp=reduce?1:CFG.lerp,dragZoom=reduce?1:CFG.zoomLevel;\n  var s={drag:false,px:0,py:0,ox:0,oy:0,tx:0,ty:0,z:1,tz:1};\n  function resize(){var dpr=Math.min(devicePixelRatio||1,2),w=root.clientWidth,h=root.clientHeight;cv.width=w*dpr;cv.height=h*dpr;gl.viewport(0,0,cv.width,cv.height);gl.uniform2f(U('uResolution'),w,h)}\n  resize();new ResizeObserver(resize).observe(root);\n  (function frame(){s.ox+=(s.tx-s.ox)*lerp;s.oy+=(s.ty-s.oy)*lerp;s.z+=(s.tz-s.z)*lerp;gl.uniform2f(U('uOffset'),s.ox,s.oy);gl.uniform1f(U('uZoom'),s.z);gl.drawArrays(gl.TRIANGLE_STRIP,0,4);requestAnimationFrame(frame)})();\n  root.addEventListener('pointerdown',function(e){e.preventDefault();root.setPointerCapture(e.pointerId);s.drag=true;s.px=e.clientX;s.py=e.clientY});\n  root.addEventListener('pointermove',function(e){var r=cv.getBoundingClientRect();gl.uniform2f(U('uMousePos'),e.clientX-r.left,e.clientY-r.top);if(!s.drag)return;var dx=e.clientX-s.px,dy=e.clientY-s.py;if((Math.abs(dx)>2||Math.abs(dy)>2)&&s.tz===1)s.tz=dragZoom;s.tx-=dx*.003;s.ty+=dy*.003;s.px=e.clientX;s.py=e.clientY});\n  function end(){s.drag=false;s.tz=1}\n  root.addEventListener('pointerup',end);root.addEventListener('pointercancel',end);\n  root.addEventListener('pointerleave',function(){gl.uniform2f(U('uMousePos'),-1,-1);end()});\n});\n})();",
        "react": "",
        "dependencies": [],
        "install": "",
        "licence_notice": ""
    }
}