<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: leoalho</title>
    <description>The latest articles on DEV Community by leoalho (@leoalho).</description>
    <link>https://dev.to/leoalho</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1011409%2F45412698-8fba-43af-a084-08990fe44cc5.jpg</url>
      <title>DEV Community: leoalho</title>
      <link>https://dev.to/leoalho</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/leoalho"/>
    <language>en</language>
    <item>
      <title>SEO checklist for early startups</title>
      <dc:creator>leoalho</dc:creator>
      <pubDate>Tue, 03 Feb 2026 22:56:24 +0000</pubDate>
      <link>https://dev.to/leoalho/seo-checklist-for-early-startups-2ipn</link>
      <guid>https://dev.to/leoalho/seo-checklist-for-early-startups-2ipn</guid>
      <description>&lt;p&gt;The past month I have been building &lt;a href="https://tableport.gg" rel="noopener noreferrer"&gt;Tableport&lt;/a&gt; as a contractor/CTO as a service (CaaS). We have been moving fast  but today I noticed that our SEO is crap. Now, SEO has never been my most favourite part, but for an early stage startup it is essential to get as many eye pairs on your application as soon as possible, and as in any startup it is usually up to the founders  to set it up. I try to keep it simple and at the moment I am not using any paid tools, for me Google Search Console provides all the data for me to be happy.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a sitemap.xml file&lt;/li&gt;
&lt;li&gt;Create a robots.txt file&lt;/li&gt;
&lt;li&gt;Create a llms.txt file&lt;/li&gt;
&lt;li&gt;Add site to Google Search console

&lt;ul&gt;
&lt;li&gt;Check that the site is indexed&lt;/li&gt;
&lt;li&gt;Resolve any potential issues&lt;/li&gt;
&lt;li&gt;Add your sitemap.xml&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Add site to Bing Webmaster tools (I usually import from google search)

&lt;ul&gt;
&lt;li&gt;Check that the site is indexed&lt;/li&gt;
&lt;li&gt;Add your sitemap.xml&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Setup an analytics platform*&lt;/li&gt;

&lt;li&gt;Add metadata to your relevant pages (meta titles and descriptions JSON-LD and OpenGraph cards)&lt;/li&gt;

&lt;li&gt;Setup Google Lighthouse review**&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;* For analytics platforms I have previously gone the route of Google Analytics and Posthog. But more recently I have tried out more light weighted self-hosted solutions, mainly Plausible CE and Umami. Both of them are cookie-less and do not require explicit consent as per GDPR. I have settled with Umami since it provides better and easier user management compared to Plausible. Also its server footprint is a lot smaller than with Plausible, because plausible uses ClickHouse as its analytical database, which can easily take 500 MB of RAM. It does not sound like much but I try to run most of my services on a single instance and RAM is usually the primary limiting factor.&lt;/p&gt;

&lt;p&gt;** I have google lighthouse installed as a global npm package. I run it from time to time. In my opinion it would be a bit overkill to have it run as a part of the CI/CD pipeline.&lt;/p&gt;

&lt;p&gt;Originally posted at &lt;a href="https://alho.dev/posts/seo" rel="noopener noreferrer"&gt;my personal website&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>startup</category>
    </item>
    <item>
      <title>Picomap - the smallest JS web map</title>
      <dc:creator>leoalho</dc:creator>
      <pubDate>Tue, 12 Dec 2023 10:09:21 +0000</pubDate>
      <link>https://dev.to/leoalho/picomap-the-smallest-js-web-map-18o0</link>
      <guid>https://dev.to/leoalho/picomap-the-smallest-js-web-map-18o0</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In my last series, I wrote a minimal raster tile server. We will now expand the project to the frontend by writing the smallest JS web map that I am aware of (please correct me if I am wrong) in under 100 lines of native js.&lt;br&gt;
Let us start by some basics. A map client is a way to display a map to the user. For this project, I spied on the most popular map clients (google maps, leaflet, mapbox, openlayers). I decided to mock leaflet because I am most familiar with leaflet, plus leaflet is the only one of the libraries listed that does not render the map on a canvas element, but instead uses multiple nested div elements. This sounded more simple for me so I went with it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Minimum requirements
&lt;/h2&gt;

&lt;p&gt;I had the following minimum requirements for my map client:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The map should display a map of set width and height&lt;/li&gt;
&lt;li&gt;The user should be able to set the initial coordinates of the map&lt;/li&gt;
&lt;li&gt;The user should be able to navigate on the map&lt;/li&gt;
&lt;li&gt;The user should be able to zoom in and out on the map.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Structure
&lt;/h2&gt;

&lt;p&gt;With the requirement specification in mind I came up with the following structure displayed in an XML fashion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;Container&amp;gt;
  &amp;lt;MapLayer&amp;gt;
    &amp;lt;Tile&amp;gt;&amp;lt;/Tile&amp;gt;
    ...
    &amp;lt;Tile&amp;gt;&amp;lt;/Tile&amp;gt;
  &amp;lt;/MapLayer&amp;gt;
  &amp;lt;ControlLayer&amp;gt;
    &amp;lt;ButtonUp&amp;gt;&amp;lt;/ButtonUp&amp;gt;
    &amp;lt;ButtonLeft&amp;gt;&amp;lt;/ButtonLeft&amp;gt;
    &amp;lt;ButtonRight&amp;gt;&amp;lt;/ButtonRight&amp;gt;
    &amp;lt;ButtonDown&amp;gt;&amp;lt;/ButtonDown&amp;gt;
    &amp;lt;ZoomIn&amp;gt;&amp;lt;/ZoomIn&amp;gt;
    &amp;lt;ZoomOut&amp;gt;&amp;lt;/ZoomOut&amp;gt;  
  &amp;lt;/ControlLayer&amp;gt;
&amp;lt;/Container&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Tile elements are img elements, the buttons naturally button elements and all else div elements.&lt;br&gt;
The MapLayer and ControlLayer both have position absolute so they are displayed on top of each other.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem solving
&lt;/h2&gt;

&lt;p&gt;I was now faced with the following problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What is the tile number of the current position?&lt;/li&gt;
&lt;li&gt;On what pixel (approximately) is the current position on the tile?&lt;/li&gt;
&lt;li&gt;How should these tiles be positioned so that the current position is in the middle &lt;/li&gt;
&lt;li&gt;How many tiles should be rendered?&lt;/li&gt;
&lt;li&gt;How to zoom in and out&lt;/li&gt;
&lt;li&gt;How to move around&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let's solve these together. I tried to keep the explanations compact and I am planning on writing a while dedicated article on webMercator.&lt;/p&gt;

&lt;p&gt;1: To solve the tile number of a given position we need the longitude, latitude and desired zoom level. Tiles are usually denoted in z/x/y format, where z is the zoom level and x and y are the x and y coordinates. In web mercator the whole world is displayed as a square and depending on the zoom level, this square is divided to 2^z tiles. The x and y coordinates range from 0 to ^2.&lt;br&gt;
Fetching the tile's x-coordinate is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const lon2tile = (lon, zoom) =&amp;gt; Math.floor(relLon(lon) * 2 ** zoom);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We first calculate the relative longitude and latitude on the whole webmercator map (between 0 and 1). Calculating the relative longitude is simple. we get it by dividing (lon+180)/360. We use lon+180 here because we use the webMercator convention of displaying longitude as a value of [-180,180]. To get the correct tile number, we multiply the relative longitude with the amount of tile columns on the current zoom level (2**zoom) and take Math.floor to get the tile number as an integer. &lt;/p&gt;

&lt;p&gt;Getting the tile's y coordinate is not as straightforward. This has to do with the conformal nature of the webmercator projection. In order to retain conformity, the latitudal distances get more distorted the more north and south we move from the equator. The distortion is non linear (1/cos(φ) == sec(φ) to be precise, where φ is the latitude). So to get the relative position on the y axis on the webmercator map, we have to integrate the secant, with the &lt;a href="https://en.wikipedia.org/wiki/Integral_of_the_secant_function" rel="noopener noreferrer"&gt;integral of the secant&lt;/a&gt; function&lt;br&gt;
we get&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const relLat = lat =&amp;gt; ((1 -Math.log(Math.tan((lat * Math.PI / 180) / 2 + Math.PI / 4)) /Math.PI) /2)
const lat2tile = (lat, zoom) =&amp;gt; Math.floor(relLat(lat) * 2 ** zoom);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We first convert the latitudes to radians by multiplying the latitude with Math.PI/180. We then calculate the integral of the secant with Math.log(Math.tan(φ/2+Math.PI/4)). We then get the relative position by dividing the secant with pi and lastly we convert from a center based coordinate to a top based coordinate and as with the x coordinate we multiply with the amount of tiles and use Math.floor to get the closest integer rounded down.&lt;/p&gt;

&lt;p&gt;2: Now that we know the tile coordinates, whe also need the pixel coordinates on the tile of the current position. For this we determine first the amount of pixels on the whole map on a given zoom level and then we use the same functions as in 1 to get the relative position on thewebmercator map. We then multiply the relative position with the number of pixels on a given zoom level. Round it down and calculate modulo 256 to get the position on the current tile.&lt;/p&gt;

&lt;p&gt;3: To get the map to be centered around the current position I decided to build the map view so that the current tile is placed in the middle of the map (the center point of the map and the tile are the same). We then offset the map layer, which is a div containing all the displayed tiles by a difference between the enter point of the tile and the pixel coordinates of the current position calculated previously.&lt;/p&gt;

&lt;p&gt;4: We place the tile of the curren position in the middle of the map so we need the same amount of tiles on the both sides of the current tile both horizonally and vertically. The map layer has the overflow hidden style property, so any tiles outside the map layer are not displayed. Calculating the amount of tiles needed is a simple function&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const nTiles = length =&amp;gt; Math.ceil((length/2 - 128) / 256)+1;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We add one tile extra on each side because if the current position is close to an edge on the tile the offset can be so large that without an extra tile we would render empty space.&lt;/p&gt;

&lt;p&gt;5,6: Moving around and zooming in/out is done simply by modifying the lon/lat and zoom properties of the map object and then rendering the map again. The current code allows only a really simple way to move around: clicking the arrow buttons moves the center point by one tile's length.&lt;/p&gt;

&lt;p&gt;Aside from mathematical problems let's take a look at the code that renders the map.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { lon2tile, lat2tile, tileOffset, nTiles, createElement } from "./utils.js";

export default class Picomap {
  constructor(height = 500, width = 500, lon = 24.4391, lat = 60.5, zoom = 10, id="map", source="https://tile.openstreetmap.org") {
    this.height = height;
    this.width = width;
    this.lat = lat;
    this.lon = lon;
    this.zoom = zoom;
    this.source = source
    this.map = document.getElementById(id);
    this.map.style = `height: ${this.height}px; width: ${this.width}px; overflow: hidden; transform: translate3d(0px,0px,0px)`;
  }

  #createButton(text, x,y,z, left, top){
    let button = createElement("button");
    button.innerText = text;
    button.style = `width: 20px; position: absolute; top: ${top}px; left: ${left}px`;
    button.addEventListener("click", () =&amp;gt; this.#move(x,y,z));
    return button
  }

  #createControlLayer() {
    let controlLayer = createElement("div");
    controlLayer.style = "height: 100%; width: 100%; position: absolute; top: 0px; left: 0px";
    controlLayer.append(this.#createButton("\u25B2", 0, 1, 0, 40, 20)); //Up
    controlLayer.append(createElement("br"));
    controlLayer.append(this.#createButton("\u25C0", -1, 0, 0, 20, 40)); //Left
    controlLayer.append(this.#createButton("\u25B6", 1, 0, 0, 60, 40)); //Right
    controlLayer.append(createElement("br"));
    controlLayer.append(this.#createButton("\u25BC", 0, -1, 0, 40, 60)); //Down
    controlLayer.append(createElement("br"));
    controlLayer.append(this.#createButton("+", 0, 0, 1, 40, 100));
    controlLayer.append(createElement("br"));
    controlLayer.append(this.#createButton("-", 0, 0, -1, 40, 120));
    this.map.append(controlLayer);
  }

  #createTile(x, y, z, transX, transY) {
    const tile = createElement("img");
    tile.src = `${this.source}/${x}/${y}/${z}.png`;
    tile.alt = "";
    tile.style = `width: 256px; height: 256px; opacity: 1; transform: translate3d(${transX}px, ${transY}px, 0px); display: block; position: absolute`;
    return tile;
  }

  #renderTiles() {
    let centerX = lon2tile(this.lon, this.zoom);
    let centerY = lat2tile(this.lat, this.zoom);
    let offset = tileOffset(this.zoom, this.lon, this.lat);
    this.mapLayer.style.transform = `translate3d(${128 - offset.x}px,${128 - offset.y}px,0px)`;
    const tiles = [];
    for (let i = -nTiles(this.height); i &amp;lt;= nTiles(this.height); i++) {
        for (let j = -nTiles(this.width); j &amp;lt;= nTiles(this.width); j++) {
          console.log(i,j)
          let transY = (this.height / 2 - 128) + i * 256;
          let transX = (this.width / 2 - 128) + j * 256;
          tiles.push(this.#createTile(this.zoom, centerX + j, centerY + i, transX, transY));
        }
    }
    this.mapLayer.replaceChildren(...tiles);
  }

  #createMapLayer() {
    this.mapLayer = createElement("div");
    this.mapLayer.style = "height: 100%; width: 100%";
    this.#renderTiles();
    this.map.append(this.mapLayer);
  }

  #move(x,y,z){
    this.lon += x*360/(Math.pow(2,this.zoom));
    this.lat += y*170.12/(Math.pow(2,this.zoom));
    this.zoom += z;
    this.#renderTiles();
  }

  initialize() {
    this.#createMapLayer();
    this.#createControlLayer();
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is almost all the code for the map client, only the helper functions for calculating the positions have been omitted. We have a class, Picomap, which accepts the following attributes, none of which are required: height, width, lat, lon, zoom, id. The initialize method creates a DOM tree according to the structure described above. The buttons for moving and zooming are initialized with the move onclick method. &lt;/p&gt;

&lt;p&gt;Here is a minimal example how to use the map: &lt;br&gt;
Index.html:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang="en"&amp;gt;
  &amp;lt;head&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;div id="map"&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;script src="https://unpkg.com/picomap/dist/picomap.js"&amp;gt;&amp;lt;/script&amp;gt;
    &amp;lt;script src="./index.js"&amp;gt;&amp;lt;/script&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;index.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const mapInstance = new Picomap();
mapInstance.initialize();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here is the end result:&lt;br&gt;
&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Funoe2750n7r6z4oxa29k.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Funoe2750n7r6z4oxa29k.gif" alt="Picomap example"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The source code can be seen in &lt;a href="https://github.com/leoalho/picomap" rel="noopener noreferrer"&gt;my github repo&lt;/a&gt;. In total we have used &amp;lt;100 lines of JS without any outside dpendencies. &lt;/p&gt;

&lt;p&gt;I highly recommend everybody to do these kind of projects them selves. It is a good way to uphold ones basic JS skills and it makes one appreciate all the functionality that comes with the libraries we use. What is your opinion with the end result? Would you have ended with the same design choices as I did?&lt;/p&gt;

&lt;p&gt;Also here are some ideas to play with if you want to fork the project and experiment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add animation to moving and zooming&lt;/li&gt;
&lt;li&gt;Add drag to move&lt;/li&gt;
&lt;li&gt;Make it possible to rotate the map&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>map</category>
      <category>javascript</category>
      <category>gis</category>
    </item>
    <item>
      <title>A minimalist raster tile server with express and postGIS - part 3. Speeding things up</title>
      <dc:creator>leoalho</dc:creator>
      <pubDate>Thu, 31 Aug 2023 05:19:26 +0000</pubDate>
      <link>https://dev.to/leoalho/a-minimalist-raster-tile-server-with-express-and-postgis-part-3-speeding-things-up-1b7</link>
      <guid>https://dev.to/leoalho/a-minimalist-raster-tile-server-with-express-and-postgis-part-3-speeding-things-up-1b7</guid>
      <description>&lt;p&gt;So far we have created a minimal map tile server that renders it tiles with postGIS. Our tiles have slowly gotten some features on it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--i3Pe0rZY--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5hvmt2xlvkqt59y970qe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--i3Pe0rZY--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5hvmt2xlvkqt59y970qe.png" alt="Image description" width="768" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before any optimization the mean times for rendering the maps above have been 1.1, 2.4 and 5.4 seconds per tile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding Spatial indexes
&lt;/h2&gt;

&lt;p&gt;Let us begin with indexing. For my map I have been working with two tables. Simplified_land_polygons downloaded from &lt;a href="https://osmdata.openstreetmap.de/data/land-polygons.html"&gt;osm data&lt;/a&gt; and planet_osm_line which includes &lt;a href="https://download.geofabrik.de/europe/finland.html"&gt;Finland's data from the whole planet-osm dataset. &lt;/a&gt;. Spatial indexing works in a similar manner to any other index in databases. However, only some of postGIS functions use spatial indexes, in our project it is only ST_intersects that uses spatial indexing. Spatial indexes index the bounding boxes of geometries. When running functions that can use spatial indexes, the database first evaluates the bounding boxes and after that all of the geometries that have their bounding boxes fulfil the functions. The bounding boxes are saved in a data structure called the &lt;a href="https://en.wikipedia.org/wiki/R-tree"&gt;R-tree&lt;/a&gt;, which is a balanced search tree. Contrary to B-trees, instead of comparing sizes we compare we compare which bounding boxes are inside of each other.&lt;/p&gt;

&lt;h3&gt;
  
  
  A small example how spatial indexing works.
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--xJRs9gPZ--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hfgrmx9omzzfgoqnghb7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--xJRs9gPZ--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hfgrmx9omzzfgoqnghb7.png" alt="Image description" width="800" height="253"&gt;&lt;/a&gt;&lt;br&gt;
In the figure above we have four geometries: a line, a triangle, a rhombus, and a polygon. In the second image we create a spatial index for the geometries. In the third we call the function ST_intersects to find all the geometries that intersect with the polygon. Now instead of evaluating all of the geometries straight away it first evaluates the bounding boxes. &lt;/p&gt;
&lt;h3&gt;
  
  
  Adding the indexes
&lt;/h3&gt;

&lt;p&gt;I added the indexes directly via psql with the &lt;code&gt;CREATE INDEX {index_name} ON {table_name} USING GIST ({column name});&lt;/code&gt; After that I ran the &lt;code&gt;ANALYZE {table_name};&lt;/code&gt; command to be sure that postgreSQL's statistics system is up to date. Indexing the land polygon table did not have any meaningful effect on the rendering time but indexing the planet_osm_line table did reduce the mean rendering time from 5.4 seconds to 4.3 seconds. This has most likely to do with the table sizes: land polygons has 63 539 rows and planet_osm_line has 2 835 593 rows. This is of course still an unacceptably high number, but it is already a -19% reduction. Note that I am running the server on my laptop, so the processor is not the best possible.&lt;/p&gt;
&lt;h2&gt;
  
  
  Adding a cache
&lt;/h2&gt;

&lt;p&gt;It should be at this time obvious that rendering each tile for every request is computationally way too intensive. Let us then add a cache. I am going to use a rather simple setup by using Redis. Redis saves all its data in memory so it can serve values quickly. I set up a Redis docker container with port bindings to port 6379 (the default port used by Redis). Because the data works in memory, I do not want the cache to become too large, so I created a redis.conf file, with the following configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;maxmemory 200mb
maxmemory-policy allkeys-lru
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The memory-policy tells redis how to act when the maxmemory is reached. I used the lru (least recently used) policy. So, in other words when the cache is full, redis will remove the least recently used tile from the cache. Note! As explained in Redis' &lt;a href="https://redis.io/docs/reference/eviction/"&gt;documentation&lt;/a&gt;, Redis does not actually know which key is the least recently used, instead it uses an approximation, so the truly least recently used key does not necessarily get removed. In our case this does not have any meaningfull impact on the workings of our server. We can now start the dockerfile with the command &lt;code&gt;$ docker run -v /myredis/conf:/usr/local/etc/redis --name rediscache redis redis-server /usr/local/etc/redis/redis.conf&lt;/code&gt;. By default Redis saves snapshots of the dataset on disk, so our cache persists even if we need to restart or pause the container.&lt;/p&gt;

&lt;p&gt;We now have the following middleware function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app.get("/tiles/:z/:x/:y", async function (req, res) {
  const { z, x, y } = req.params;
  if (pathMakesSense(parseInt(z), parseInt(x), parseInt(y))) {
    try {
      redisValue = await redis_client.get(`${z}_${x}_${y}`);
      if (redisValue) {
        res.writeHead(200, {
          "Content-Type": "image/png",
          "Content-Length": Buffer.from(redisValue, "hex").length,
        });
        res.end(Buffer.from(redisValue, "hex"));
      } else {
        let response = await pg_client.query(query, [z, x, y]);
        let img = response.rows[0].st_aspng;
        res.writeHead(200, {
          "Content-Type": "image/png",
          "Content-Length": img.length,
        });
        res.end(img);
        redis_client.set(`${z}_${x}_${y}`, img.toString("hex"));
      }
    } catch (error) {
      console.log(error);
    }
  } else {
    res.writeHead(400);
    res.end("Incorrect path");
  }
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We first extract variables z, x, y from the req.params object. We then check that the path is valid with the pathMakesSense helper function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const pathMakesSense = (z, x, y) =&amp;gt; {
  const maxCoord = 2 ** z;
  return z &amp;gt;= 0 &amp;amp;&amp;amp; z &amp;lt;= 20 &amp;amp;&amp;amp; x &amp;gt;= 0 &amp;amp;&amp;amp; x &amp;lt; maxCoord &amp;amp;&amp;amp; y &amp;gt;= 0 &amp;amp;&amp;amp; y &amp;lt; maxCoord;
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We then check if the tile is saved in the Redis cache. If so, we serve it directly from the cache. Redis saves it values as strings, so we have to form a buffer from the string before sending it via our response. If the tile is not in our cache, the server renders the tile like before. &lt;/p&gt;

&lt;h3&gt;
  
  
  Prerendered tiles
&lt;/h3&gt;

&lt;p&gt;In addition to a dynamic cache I also wante to have prerendered tiles. Let's do some simple calculations to see how many tiles we want to prerender. Like stated in the first part, each zoom level z contains 

&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;z4z^4 &lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;z&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;4&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 tiles. If we want to render all tiles from 0 to n, we would need 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;∑i=0n4i=1−4n+1−3\displaystyle\sum_{i=0}^n 4^i = \frac {1-4^{n+1}}{-3} &lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mop op-limits"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;span class="mrel mtight"&gt;=&lt;/span&gt;&lt;span class="mord mtight"&gt;0&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="mop op-symbol large-op"&gt;∑&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;n&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;4&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;i&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;−&lt;/span&gt;&lt;span class="mord"&gt;3&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;−&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;4&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;n&lt;/span&gt;&lt;span class="mbin mtight"&gt;+&lt;/span&gt;&lt;span class="mord mtight"&gt;1&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 tiles. Our png images have three bands, each with a 8 bit (one byte) value. So for a 256*256 pixel png, the maximum size (without taking the headers and magic number into account) would be 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;256⋅256⋅3=196.6kB256 \cdot 256 \cdot 3 = 196.6 kB &lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;256&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;⋅&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;256&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;⋅&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;3&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;196.6&lt;/span&gt;&lt;span class="mord mathnormal"&gt;k&lt;/span&gt;&lt;span class="mord mathnormal"&gt;B&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
. Fortunately png uses lossless compression, and the ST_aspng function is able to compress the pngs. Since our tiles are really simple without many features, a compressed png of our tiles is not anywhere close to 196 kB. I estimated that 5 kB/tile is more realistic. We can now generate the following table&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--zvA9b-Ae--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/evu0m9vi3go1obgs4ncf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--zvA9b-Ae--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/evu0m9vi3go1obgs4ncf.png" alt="Image description" width="438" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From the table above we can easily evaluate up to which zoom level we want to create our prerendered tile cache. I choose up to level 6, because in addition to data size requirements we also have o take into account the computational requirements. It takes for my laptop around 4s to render each tiles, so it took around 4 hours to render the tiles up to level 6. I implemented the prerendered cache with Redis aswell. I wanted the prerendered cache and the dynamic cahce to be clearly their own separate systems, so I started a new Redis container running on a different port (the port is arbitrary, I chose 6380). I added an own file for prerendering the tiles (&lt;a href="https://github.com/leoalho/tileserver/blob/main/src/prerenderer.js"&gt;.src/prerenderer.js&lt;/a&gt;), which can be run with the command &lt;code&gt;npm run prerender {n}&lt;/code&gt;, where n is the zoom level up to which we want to render our tiles. In the main server we add the following clause to our middleware function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (z &amp;lt;= RENDEREDTILES) {
  let preRenderedRedisValue = await preRendered_client.get(
    `${z}_${x}_${y}`
  );
  res.writeHead(200, {
  "Content-Type": "image/png",
  "Content-Length": Buffer.from(preRenderedRedisValue,"hex").length,
  });
  res.end(Buffer.from(preRenderedRedisValue, "hex"));
  return;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;We now have a tile server that renders .png tiles directly with postGIS. We have created two caches, one dynamic and one prerendered. Let's see how it performs. The first gif is a reminder of the situation before we started optimizing our speed and the second image is the current situation. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--yIM6wXXf--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/irlixn0942s27g9ya3zu.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--yIM6wXXf--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/irlixn0942s27g9ya3zu.gif" alt="Image description" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--rmlvn9qL--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/18obsbo4bm9azd3i0b87.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--rmlvn9qL--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/18obsbo4bm9azd3i0b87.gif" alt="Image description" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The code for this part can be found in &lt;a href="https://github.com/leoalho/tileserver"&gt;this github repository&lt;/a&gt; under the 'main' branch.&lt;br&gt;
This is probably the last part of this series. &lt;br&gt;
Thank you for reading, comments, critique and suggestions are welcome as always.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>gis</category>
      <category>postgis</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A minimalist raster tile server with express and postGIS - part 2. Adding more features</title>
      <dc:creator>leoalho</dc:creator>
      <pubDate>Tue, 29 Aug 2023 04:51:56 +0000</pubDate>
      <link>https://dev.to/leoalho/a-minimalist-raster-tile-server-with-express-and-postgis-part-2-adding-more-features-2d0c</link>
      <guid>https://dev.to/leoalho/a-minimalist-raster-tile-server-with-express-and-postgis-part-2-adding-more-features-2d0c</guid>
      <description>&lt;p&gt;For the previous part see &lt;a href="https://dev.to/leoalho/a-minimalist-raster-tile-server-with-express-and-postgis-79i"&gt;here&lt;/a&gt;. The code for this part can be seen in &lt;a href="https://github.com/leoalho/tileserver/tree/features"&gt;the project's github repository&lt;/a&gt; under branch "features".&lt;/p&gt;

&lt;p&gt;A quick recap: The goal of this project is to create a tile server and use postGIS to render the tiles. In the last part we managed to get a simple tile server with only around 60 lines of Javascript. The map was quite grim, so let us now add some more features to the map.&lt;/p&gt;

&lt;p&gt;The data that we used for our first version only contained grey land polygons. Let's now create a nicer look for adding different colours for the landmass, water and let's add a coastline as well. The code for the backend remains the same, only the sql query looks different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WITH rasters AS (
    SELECT ST_AsRaster(ST_collect(Array(SELECT ST_TileEnvelope($1,$2,$3))), 256, 256, ARRAY['8BUI', '8BUI', '8BUI'], ARRAY[179, 208, 255], ARRAY[0,0,0]) AS rast
    UNION ALL
    SELECT ST_AsRaster(
      ST_collect(Array(
          SELECT ST_Intersection(geom,ST_TileEnvelope($1,$2,$3)) FROM ${TABLE} UNION
          SELECT ST_boundary(ST_TileEnvelope($1,$2,$3))
      )
    ), 256, 256, ARRAY['8BUI', '8BUI', '8BUI'], ARRAY[251, 255, 194], ARRAY[0,0,0]) AS rast
    UNION ALL
    SELECT ST_AsRaster(
        ST_collect(Array(
            SELECT ST_boundary(ST_Intersection(geom,ST_TileEnvelope($1,$2,$3))) FROM ${TABLE} UNION
            SELECT ST_boundary(ST_TileEnvelope($1,$2,$3))
        )
    ), 256, 256, ARRAY['8BUI', '8BUI', '8BUI'], ARRAY[1,1,1], ARRAY[0,0,0]) AS rast
)
SELECT ST_AsPNG(ST_Union(rast)) FROM rasters;
`;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query is quite straightforward. We first create three separate rasters. As mentioned in the last post the &lt;a href="https://postgis.net/docs/en/RT_ST_AsRaster.html"&gt;ST_asRaster&lt;/a&gt; function accepts many different parameter configurations. Here I have used the synopsis that accepts the following parameters: &lt;code&gt;(geometry geom, integer width, integer height, text[] pixeltype, double precision[] value=ARRAY[1], double precision[] nodataval=ARRAY[0], double precision upperleftx=NULL, double precision upperlefty=NULL, double precision skewx=0, double precision skewy=0, boolean touched=false)&lt;/code&gt;&lt;br&gt;
. The colour is set with the value parameter in [Red, Green, Blue] format. &lt;/p&gt;

&lt;p&gt;In our query the first raster contains the water areas and is displayed in blue. Or to be precise, it actually contains the whole tile as a rectangle but when stacked with the land polygon it leaves the water areas blue. The second raster is the raster generated in the last part, it renders the land polygons, this time in a yellowish colour. The third raster displays the coastlines in black, the query for the third raster is rather similar to the second but we have one extra function, ST_boundary, which returns the boundaries of the geometry as a polyline.&lt;/p&gt;

&lt;p&gt;The three rasters are then combined with the postGIS function &lt;a href="https://postgis.net/docs/en/RT_ST_Union.html"&gt;ST_Union&lt;/a&gt;. ST_Union takes an set of rasters (or geometries) and returns a single raster. There are several protocols for handling intersecting areas (LAST (default), FIRST, MIN, MAX, COUNT, SUM, MEAN, RANGE). The default value LAST is the best for our case.&lt;/p&gt;

&lt;p&gt;The tiles now have the following look:&lt;br&gt;
&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--1Sj79Eng--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kaosmeob0z99yhgxvfmy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--1Sj79Eng--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kaosmeob0z99yhgxvfmy.png" alt="http://localhost:8080/tiles/10/581/297" width="256" height="256"&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Overall, the map looks much nicer than before. There is still the problem (or feature) that the map displays the borders of the tiles as shown below. &lt;br&gt;
I am into sailing and especially marine cartography if it was not yet obvious from the colour scheme used. Perhaps that is the reason as well that the tile borders do not bother me that much.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--7FhIlxk8--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cy2in6ua0701k8bjy1ro.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--7FhIlxk8--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cy2in6ua0701k8bjy1ro.png" alt="Image description" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The reason for the tile borders to be displayed is twofold. Firstly  in our last example we only had one raster, if we remove the &lt;code&gt;SELECT ST_boundary(ST_TileEnvelope($1,$2,$3))&lt;/code&gt; part of our query, postgres gives us errors at some tiles and some tiles are skewed. The skewed tiles are tiles that do not contain land polygon geometries on the border of each side of the tile. By adding the boundary of the envelope we effectively prevent this but then the boundary gets rendered as well. &lt;br&gt;
Secondly in a similar vein in today's example when using ST_Union you have to have the exact alignment with each raster for the function to work. &lt;br&gt;
I have not come up with a good way to get around this, perhaps one way could be using another way of calling the ST_asRaster. If someone else has a suggestion, I am more than happy to learn, I am at no means an expert postGIS user.&lt;/p&gt;

&lt;p&gt;We could expand this as much as we want by adding more raster layers. Our initial dataset of simplified land polygons does not provide much more to render, but if we download for example the osm basemap, only the sky is the limit. For example, here I have added the main roads by adding the following raster to our query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    SELECT ST_AsRaster(
        ST_collect(Array(
            SELECT ST_Intersection(ST_collect(Array(SELECT way FROM ${TABLE2} WHERE highway IN ('motorway', 'trunk', 'primary'))),ST_TileEnvelope($1,$2,$3))  UNION
            SELECT ST_boundary(ST_TileEnvelope($1,$2,$3))
        )
    ), 256, 256, ARRAY['8BUI', '8BUI', '8BUI'], ARRAY[1,1,1], ARRAY[0,0,0]) AS rast
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query is again straightforward, TABLE2 is the name of the newly created table, in my case "planet_osm_line". OSM tables have a lot of different columns, and at least to me it is not always intuitive which ones are most relevant, here QGIS or any other GIS software is a good companion for quick visualizing. Also the &lt;a href="https://wiki.openstreetmap.org/wiki/Key:highway"&gt;OSM wiki&lt;/a&gt; is a great companion. Now the map has the following look.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--lXf9ybE4--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j39fftxqcewx0lkqb5qg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--lXf9ybE4--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j39fftxqcewx0lkqb5qg.png" alt="Image description" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We probably should add some conditions for what kind of roads we should render on what zoom level, if you look on Google maps or OSM the roads are only visible on certain zoom levels, but this could be the topic of a future article.&lt;/p&gt;

&lt;p&gt;The main problem is again speed, at this moment it is even worse than before. Again, postGIS is not meant for rendering raster tiles on the go, so I do not blame it for it. But still, with the current configuration, it takes around 2.3 seconds per tile and around 4 seconds per tile with the roads added. In the last part, with the monotone tiles, it took around 1 second to render a tile.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--DmAPSj02--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kvwoyw9qh26ifqtjququ.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--DmAPSj02--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kvwoyw9qh26ifqtjququ.gif" alt="Image description" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Speeding up our server will be the topic of our next part. Thank you again for reading. Any comments are more than welcome.&lt;/p&gt;

</description>
      <category>postgis</category>
      <category>gis</category>
      <category>tutorial</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A minimalist raster tile server with express and postGIS</title>
      <dc:creator>leoalho</dc:creator>
      <pubDate>Sun, 27 Aug 2023 03:15:44 +0000</pubDate>
      <link>https://dev.to/leoalho/a-minimalist-raster-tile-server-with-express-and-postgis-79i</link>
      <guid>https://dev.to/leoalho/a-minimalist-raster-tile-server-with-express-and-postgis-79i</guid>
      <description>&lt;p&gt;If one starts to read about rendering maps, it is easy to get lost in the plethora of different technologies involved. I have read about several blogposts on how to implement a vector tile server solely with postGIS but not many how to implement a raster tile server.&lt;/p&gt;

&lt;p&gt;The objective of this project is to create an as minimalist as possible raster tileserver. It is not going to be flashy nor is it going to be quick, but my ethos in learning new skills and technologies is to try to make an implementation of the new skill as independently and simply as possible. This adds alot of appreciation to the available solutions and it also helps to understand what problems each of the technologies involved try to solve.&lt;/p&gt;

&lt;p&gt;The code for the project can be found in &lt;a href="https://github.com/leoalho/tileserver"&gt;this github repository&lt;/a&gt;, the code for this post is under branch “simple”.&lt;/p&gt;

&lt;p&gt;Let us start with some basic definitions so we are on the same page. A tileserver is a server which serves tiles that form a map. Tiles commonly follow the z,x,y naming standard, where z is zoom level, and x and y are coordinates on that zoom level. Zoom level 0 contains the entire globe on one tile and each subsequent zoom level contains double the amount of tiles on the x and y axes, so each zoom level it has z⁴ tiles (z² * z²).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--HNPzSj2---/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c0f9pifpr5git5agtcjs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--HNPzSj2---/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c0f9pifpr5git5agtcjs.png" alt="Image description" width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are two types of tiles: raster and vector tiles, which are rather self explanatory. In this project I will create a raster tile server. In a map application each raster tile has the same resolution, a common resolution is 256x256 pixels. The normal standard for fetching tiles is servername/x/y/z.png.&lt;/p&gt;

&lt;p&gt;Usually when creating a tile server one needs the following building blocks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The data&lt;/li&gt;
&lt;li&gt;A way to render the maps&lt;/li&gt;
&lt;li&gt;A server that handles the requests and responses&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To create a tile server we are going to need data. I will be using Openstreetmaps (OSM) data and the postgresql extension postGIS for storing the tiles.&lt;/p&gt;

&lt;p&gt;I used data from &lt;a href="https://osmdata.openstreetmap.de/data/land-polygons.html"&gt;https://osmdata.openstreetmap.de/data/land-polygons.html&lt;/a&gt; to download the data. OSM data is downloaded as OSM data (.osm) or as shapefiles (.shp). Shapefiles and OSM data can be opened with most GIS-software, I personally use QGIS. The polygons used for this project is in .shp.&lt;/p&gt;

&lt;p&gt;For the shapefile to be readable with postgres, we need to change the format of the data. I use shp2psql which is a really straightforward cli program, shp2psql is included in the postGIS bundle when downloading postGIS. For converting osm data, &lt;a href="https://osm2pgsql.org/"&gt;osm2psql&lt;/a&gt; can be used. &lt;/p&gt;

&lt;p&gt;In this example I will not use a separate rendered, but instead I will render the tiles directly with postGIS.&lt;/p&gt;

&lt;p&gt;The core problem is to now generate a sql query to render a png tile in position z,x,y. Luckily the postGIS extension contains many functions that makes this possible. I have used the following query in my project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT ST_AsPNG(
  ST_AsRaster(
      ST_collect(Array(
          SELECT ST_Intersection(geom,ST_TileEnvelope($1,$2,$3)) FROM ${TABLE} UNION
          SELECT ST_boundary(ST_TileEnvelope($1,$2,$3))
      )
  ), 256, 256, ARRAY['8BUI', '8BUI', '8BUI'], ARRAY[100,100,100], ARRAY[0,0,0])
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's go through the query. For those not so familiar with postGIS, the functions beginning with ST are postGIS functions. THe query assumes that all the geometries to be rendered are in a column calles geom.&lt;/p&gt;

&lt;p&gt;Moving from the inside out we have&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ST_Intersection(geometry A, geometry B) return a portion of geometry A and geometry B that is shared between the two geometries.&lt;/li&gt;
&lt;li&gt;ST_TileEnvelope(z,x,y): Creates a rectangular Polygon giving the extent of a tile in the xyz system.&lt;/li&gt;
&lt;li&gt;ST_collect(geometry[]) Collects geometries into a geometry collection&lt;/li&gt;
&lt;li&gt;ST_AsRaster() Converts a PostGIS geometry to a PostGIS raster&lt;/li&gt;
&lt;li&gt;ST_AsPNG() Returns the selected bands of the raster as a single png&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For further info about postGIS functions, please see the excellent &lt;a href="https://postgis.net/docs/en/"&gt;postGIS documentation&lt;/a&gt;. Especially for the AsRaster and AsPNG functions, since there are several possibilities for the arguments, so for simplicity’s sake I did not list them here&lt;/p&gt;

&lt;p&gt;The backend is rather straightforward. I will be using Express for the http server and the pg library for the postgresql API.&lt;/p&gt;

&lt;p&gt;Here is the code of the backend in its entirety, as you can see, it is really compact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;require("dotenv").config();
const { Client } = require("pg");
const express = require("express");
const path = require("path");

const PORT = process.env.PORT || 8080;
const HOSTNAME = process.env.HOSTNAME || "127.0.0.1";
const PUBLICPATH = path.join(__dirname, "./public");
const PGUSERNAME = process.env.PGUSERNAME;
const PASSWORD = process.env.PASSWORD;
const DATABASE = process.env.DATABASE;
const TABLE = process.env.TABLE;

const query = `
SELECT ST_AsPNG(
  ST_AsRaster(
      ST_collect(Array(
          SELECT ST_Intersection(geom,ST_TileEnvelope($1,$2,$3)) FROM ${TABLE} UNION
          SELECT ST_boundary(ST_TileEnvelope($1,$2,$3))
      )
  ), 256, 256, ARRAY['8BUI', '8BUI', '8BUI'], ARRAY[100,100,100], ARRAY[0,0,0])
);
`;

const pathMakesSense = (z, x, y) =&amp;gt; {
  const maxCoord = 2 ** z;
  return z &amp;gt;= 0 &amp;amp;&amp;amp; z &amp;lt;= 20 &amp;amp;&amp;amp; x &amp;gt;= 0 &amp;amp;&amp;amp; x &amp;lt; maxCoord &amp;amp;&amp;amp; y &amp;gt;= 0 &amp;amp;&amp;amp; y &amp;lt; maxCoord;
}

const client = new Client({
  user: PGUSERNAME,
  database: DATABASE,
  password: PASSWORD,
});

client.connect();

let app = express();

app.get("/tiles/:z/:x/:y", async function (req, res) {
  const { z, x, y } = req.params;
  if (pathMakesSense(parseInt(z), parseInt(x), parseInt(y))) {
    try {
        let response = await client.query(query, [z, x, y]);
        img = response.rows[0].st_aspng;
        res.writeHead(200, {
          "Content-Type": "image/png",
          "Content-Length": img.length,
        });
        res.end(img);
    } catch (error) {
      console.log(error);
    }
  } else {
    res.writeHead(400);
    res.end("Incorrect path");
  }
});

app.use(express.static(PUBLICPATH));

app.listen(PORT, HOSTNAME, () =&amp;gt; {
  console.log(`Listening on ${HOSTNAME}, port ${PORT}`);
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the code can be found in the github repository. The frontend map functionality is implemented with leaflet and is also really straightforward.&lt;/p&gt;

&lt;p&gt;Let us see how the application works. Below is a real time gif of my laptop rendering the tiles.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--PBlJGxm3--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6971vjpe3xmzdczp8qap.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--PBlJGxm3--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6971vjpe3xmzdczp8qap.gif" alt="Image description" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quite frustratingly slow, right? It takes around one second per tile for the server to render each tile. It does have its own militaresque look, but it is quite monotonous without that many features.&lt;/p&gt;

&lt;p&gt;he single tiles are served via servername/tiles/z/x/y. So for example &lt;a href="http://localhost:8080/tiles/10/582/296"&gt;http://localhost:8080/tiles/10/582/296&lt;/a&gt; returns the following tile.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--1ErSbSPn--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3mj3sslfukxephhfparz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--1ErSbSPn--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3mj3sslfukxephhfparz.png" alt="Image description" width="256" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thank you for reading. Any comments are welcome. In the next posts I will first make the maps a bit nicer and then make the map faster by adding a simple cache&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>gis</category>
      <category>tutorial</category>
      <category>postgis</category>
    </item>
  </channel>
</rss>
