gfw-api/true-color-tiles

View on GitHub
src/services/image.service.ts

Summary

Maintainability
C
1 day
Test Coverage
import { Context } from "koa";
import { scalePow, ScalePower } from 'd3';
import assert from "assert";
import axios, { AxiosResponse } from "axios";
import { Canvas, createCanvas, Image } from "canvas";
import logger from "logger";

const dataMaxZoom: number = 12;

class ImageService {

    static pad(num: string): string {
        const s: string = `00${num}`;
        return s.substr(s.length - 3);
    }

    static decodeGLAD(data: any, ctx: Context): any {

        const q: Record<string, any> = ctx.query;

        const startDate: number = (q.startDate === undefined) ? 0 : ImageService.dateToInt(q.startDate, 0);
        const endDate: number = (q.endDate === undefined) ? 9999 : ImageService.dateToInt(q.endDate, 9999);
        const showUnconfirmed: boolean = (q.showUnconfirmed === undefined) ? false : ImageService.strToBool(q.showUnconfirmed);

        for (let i: number = 0; i < data.length; i += 4) {

            const band3Str: string = ImageService.pad(data[i + 2].toString());
            const confidence: number = parseInt(band3Str[0], 10) - 1;
            const totalDays: number = data[i] * 255 + data[i + 1];

            if ((totalDays > 0 && totalDays >= startDate && totalDays <= endDate) && (confidence === 1 || showUnconfirmed)) {


                const intensityRaw: number = parseInt(band3Str.slice(1, 3), 10);
                let intensity: number = intensityRaw * 50;

                if (intensity > 255) {
                    intensity = 255;
                }

                data[i] = 220;
                data[i + 1] = 102;
                data[i + 2] = 153;
                data[i + 3] = intensity;

            } else {
                data[i + 3] = 0;
            }
        }

        return data;
    }


    static strToBool(boolStr: string): boolean {
        return (boolStr.toLowerCase() === 'true' || boolStr.toLowerCase() === 'yes' || boolStr === '1');
    }


    static dateToInt(yearStr: string, defaultvalue: number): number {

        function isLeapYear(year: number): boolean {
            return !(year % 4);
        }

        function dayOfYear(year: number, month: number, day: number): number {
            const dayCount: number[] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];

            let dayOfYear: number = dayCount[month] + day;
            if (month > 1 && isLeapYear(year)) dayOfYear++;
            return dayOfYear;
        }

        function baseDays(year: number): number {
            let leapYears: number = 0;
            for (let i: number = baseYear - 1; i < year; i++) {
                leapYears += isLeapYear(i) ? 1 : 0;
            }

            return (year - baseYear) * 365 + leapYears;
        }

        const baseYear: number = 2015;
        let result: number = defaultvalue;


        try {

            logger.debug(`yearStr: ${yearStr}`);

            const year: number = parseInt(yearStr.substring(0, 4), 10);
            const month: number = parseInt(yearStr.substring(5, 7), 10);
            const day: number = parseInt(yearStr.substring(8, 10), 10);

            assert(year >= baseYear);
            assert(month >= 1 && month <= 12);
            assert(day >= 1 && day <= 31);

            result = baseDays(year) + dayOfYear(year, month, day);

        } catch (e) {
            logger.debug(e);
        }

        return result;

    }

    static yearToInt(yearStr: string): number {
        return parseInt(yearStr.slice(-2), 10);
    }

    static decodeLoss(data: any, ctx: Context): any {
        const { z } = ctx.params;
        const q: any = ctx.query;

        const startYear: number = (q.startYear === undefined) ? 0 : ImageService.yearToInt(q.startYear);
        const endYear: number = (q.endYear === undefined) ? 100 : ImageService.yearToInt(q.endYear);

        const exp: number = z < 11 ? 0.3 + ((z - 3) / 20) : 1;

        const myScale: ScalePower<number, number> = scalePow()
            .exponent(exp)
            .domain([0, 256])
            .range([0, 256]);

        for (let i: number = 0; i < data.length; i += 4) {

            if (endYear >= data[i + 2] && data[i + 2] >= startYear) {

                const intensity: number = data[i];

                data[i] = 220;
                data[i + 1] = (72 - z) + 102 - (3 * myScale(intensity) / z);
                data[i + 2] = (33 - z) + 153 - ((intensity) / z);
                data[i + 3] = z < 13 ? myScale(intensity) : intensity;

            } else {
                data[i + 3] = 0;
            }
        }

        return data;
    }

    static getUrl(urlTemplate: string, coords: number[]): string {
        return urlTemplate.replace('%z', coords[2].toString()).replace('%x', coords[1].toString()).replace('%y', coords[0].toString());
    }

    static getZoomSteps(z: number): number {
        return z - dataMaxZoom;
    }

    static getTileCoords(x: number, y: number, z: number): number[] {
        if (z > dataMaxZoom) {
            x = Math.floor(x / (Math.pow(2, z - dataMaxZoom)));
            y = Math.floor(y / (Math.pow(2, z - dataMaxZoom)));
            z = dataMaxZoom;
        } else {
            y = (y > Math.pow(2, z) ? y % Math.pow(2, z) : y);
            if (x >= Math.pow(2, z)) {
                x %= Math.pow(2, z);
            } else if (x < 0) {
                x = Math.pow(2, z) - Math.abs(x);
            }
        }
        return [x, y, z];
    }

    static async getImage(reqCtx: Context): Promise<Buffer> {
        logger.info('Getting url');

        const { params } = reqCtx;
        const url: string = this.getUrl(params.urlTemplate, this.getTileCoords(params.x, params.y, params.z));

        const team: Buffer = await axios.get(url, {
            responseType: 'arraybuffer'
        }).then((response: AxiosResponse<any>) => {
            const img: Image = new Image();

            const returnPromise: Promise<Buffer> = new Promise((resolve: (value: any) => void): void => {
                img.onerror = (err: Error):void => {
                    throw err
                };
                img.onload = (): void => {
                    const canvas: Canvas = createCanvas(img.width, img.height);

                    const ctx: any = canvas.getContext('2d');

                    const zsteps: number = ImageService.getZoomSteps(params.z) | 0; // force 32bit int type

                    if (zsteps < 0) {
                        ctx.drawImage(img, 0, 0);
                    } else { // over the maxzoom, we'll need to scale up each tile
                        ctx.imageSmoothingEnabled = false; // disable pic enhancement
                        ctx.mozImageSmoothingEnabled = false;

                        // tile scaling
                        const srcX: number = (256 / Math.pow(2, zsteps) * (params.x % Math.pow(2, zsteps))) | 0;
                        const srcY: number = (256 / Math.pow(2, zsteps) * (params.y % Math.pow(2, zsteps))) | 0;
                        const srcW: number = (256 / Math.pow(2, zsteps)) | 0;
                        const srcH: number = (256 / Math.pow(2, zsteps)) | 0;
                        ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, 256, 256);
                    }
                    const I: any = ctx.getImageData(0, 0, canvas.width, canvas.height);

                    if (params.layer === 'glad') {
                        ImageService.decodeGLAD(I.data, reqCtx);
                    } else {
                        ImageService.decodeLoss(I.data, reqCtx);
                    }

                    ctx.putImageData(I, 0, 0);

                    resolve(canvas.toBuffer());
                }
            });

            img.src = Buffer.from(response.data, 'binary');

            return returnPromise;
        });

        return team;
    }

}

export default ImageService;