diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..222861c3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} diff --git a/website/src/pages/api/_generate.ts b/website/src/pages/api/_generate.ts new file mode 100644 index 00000000..2dc60e09 --- /dev/null +++ b/website/src/pages/api/_generate.ts @@ -0,0 +1,56 @@ +import { promises as fs } from "fs"; +import * as mapshaper from "mapshaper"; +import * as path from "path"; + +export const generate = async ({ + format, + shapes, + simplify, + year, + mapshaperCommands, +}: { + format: "topojson" | "svg"; + shapes: string[]; + simplify: string; + mapshaperCommands?: string[]; + year: string; +}) => { + const input = await (async () => { + const props = [...shapes].flatMap((shape) => { + return ["shp", "dbf", "prj"].map( + async (ext) => + [ + `${shape}.${ext}`, + await fs.readFile( + path.join( + process.cwd(), + "public/swiss-maps", + year, + `${shape}.${ext}` + ) + ), + ] as const + ); + }); + return Object.fromEntries(await Promise.all(props)); + })(); + + const inputFiles = [...shapes].map((shape) => `${shape}.shp`).join(" "); + + const commands = [ + `-i ${inputFiles} combine-files string-fields=*`, + simplify ? `-simplify ${simplify} keep-shapes` : "", + "-clean", + `-proj ${format === "topojson" ? "wgs84" : "somerc"}`, + ...(mapshaperCommands || []), + ].join("\n"); + + console.log("### Mapshaper commands ###"); + console.log(commands); + + const output = await mapshaper.applyCommands(commands, input); + + return format === "topojson" + ? output["output.topojson"] + : output["output.svg"]; +}; diff --git a/website/src/pages/api/_utils.ts b/website/src/pages/api/_utils.ts new file mode 100644 index 00000000..7891d81c --- /dev/null +++ b/website/src/pages/api/_utils.ts @@ -0,0 +1,114 @@ +import { either } from "fp-ts"; +import produce from "immer"; +import * as t from "io-ts"; +import { NextApiRequest, NextApiResponse } from "next"; +import { defaultOptions, Shape } from "src/shared"; + +type Format = "topojson" | "svg"; + +export const formatExtensions = { + topojson: "json", + svg: "svg", +} as Record; + +export const formatContentTypes = { + topojson: "application/json", + svg: "image/svg+xml", +} as Record; + +const Query = t.type({ + format: t.union([t.undefined, t.literal("topojson"), t.literal("svg")]), + year: t.union([t.undefined, t.string]), + shapes: t.union([t.undefined, t.string]), + simplify: t.union([t.undefined, t.string]), + download: t.union([t.undefined, t.string]), +}); + +export const parseOptions = (req: NextApiRequest, res: NextApiResponse) => { + const query = either.getOrElseW(() => undefined)( + Query.decode(req.query) + ); + + if (!query) { + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain"); + res.end("Failed to decode query"); + return; + } + + if (!query.shapes) { + res.setHeader("Content-Type", "text/plain"); + res.status(204).send("0"); + return; + } + + const options = produce(defaultOptions, (draft) => { + if (query.year) { + draft.year = query.year; + } + if (query.format) { + draft.format = query.format; + } + if (query.shapes) { + draft.shapes = new Set(query.shapes.split(",") as $FixMe); + } + }); + + return options; +}; + +export function initMiddleware(middleware: $FixMe) { + return (req: NextApiRequest, res: NextApiResponse) => + new Promise((resolve, reject) => { + middleware(req, res, (result: unknown) => { + if (result instanceof Error) { + return reject(result); + } + return resolve(result); + }); + }); +} + +const truthy = (x: T): x is Exclude => { + return Boolean(x); +}; + +export const makeMapshaperStyleCommands = ( + shapeStyles: Record< + string, + null | { + fill?: string; + stroke?: string; + } + > +) => { + return Object.entries(shapeStyles) + .map(([shapeName, style]) => { + if (style === null) { + return style; + } + return `-style target='${shapeName}' ${Object.entries(style) + .map(([propName, propValue]) => { + return `${propName}='${propValue}'`; + }) + .join(" ")}`; + }) + .filter(truthy); +}; + +const getShapeZIndex = (shape: string) => { + if (shape.includes("country")) { + return 3; + } else if (shape.includes("cantons")) { + return 2; + } else if (shape.includes("lakes")) { + return 1; + } + return 0; +}; + +export const shapeIndexComparator = (a: string, b: string) => { + const za = getShapeZIndex(a); + const zb = getShapeZIndex(b); + return za === zb ? 0 : za < zb ? -1 : 1; +}; diff --git a/website/src/pages/api/generate.ts b/website/src/pages/api/generate.ts index 5a8773a7..c057f3c9 100644 --- a/website/src/pages/api/generate.ts +++ b/website/src/pages/api/generate.ts @@ -1,47 +1,24 @@ import Cors from "cors"; -import { either } from "fp-ts"; -import { promises as fs } from "fs"; -import { enableMapSet, produce } from "immer"; -import * as t from "io-ts"; -import * as mapshaper from "mapshaper"; +import { enableMapSet } from "immer"; import { NextApiRequest, NextApiResponse } from "next"; import * as path from "path"; -import { defaultOptions, Shape } from "src/shared"; +import { generate } from "./_generate"; +import { + formatContentTypes, + formatExtensions, + initMiddleware, + parseOptions, + shapeIndexComparator, +} from "./_utils"; enableMapSet(); -async function get(url: string) { - return fetch(url) - .then((res) => res.arrayBuffer()) - .then((ab) => Buffer.from(ab)); -} - -function initMiddleware(middleware: $FixMe) { - return (req: NextApiRequest, res: NextApiResponse) => - new Promise((resolve, reject) => { - middleware(req, res, (result: unknown) => { - if (result instanceof Error) { - return reject(result); - } - return resolve(result); - }); - }); -} - const cors = initMiddleware( Cors({ methods: ["GET", "POST", "OPTIONS"], }) ); -const Query = t.type({ - format: t.union([t.undefined, t.literal("topojson"), t.literal("svg")]), - year: t.union([t.undefined, t.string]), - shapes: t.union([t.undefined, t.string]), - simplify: t.union([t.undefined, t.string]), - download: t.union([t.undefined, t.string]), -}); - export default async function handler( req: NextApiRequest, res: NextApiResponse @@ -49,90 +26,30 @@ export default async function handler( try { await cors(req, res); - const query = either.getOrElseW(() => undefined)( - Query.decode(req.query) - ); - - if (!query) { - res.statusCode = 200; - res.setHeader("Content-Type", "text/plain"); - res.end("Failed to decode query"); - return; - } - - const options = produce(defaultOptions, (draft) => { - if (query.year) { - draft.year = query.year; - } - if (query.format) { - draft.format = query.format; - } + const { query } = req; + const options = parseOptions(req, res)!; - if (query.shapes) { - draft.shapes = new Set(query.shapes.split(",") as $FixMe); - } - }); const { format, year, shapes } = options; - // console.log(year, shapes); - - const input = await (async () => { - const props = [...shapes].flatMap((shape) => { - return ["shp", "dbf", "prj"].map( - async (ext) => - [ - `${shape}.${ext}`, - await fs.readFile( - path.join( - process.cwd(), - "public", - "swiss-maps", - year, - `${shape}.${ext}` - ) - ), - ] as const - ); - }); - return Object.fromEntries(await Promise.all(props)); - })(); - - const inputFiles = [...shapes].map((shape) => `${shape}.shp`).join(" "); - - const commands = [ - `-i ${inputFiles} combine-files string-fields=*`, - // `-rename-layers ${shp.join(",")}`, - query.simplify ? `-simplify ${query.simplify} keep-shapes` : "", - "-clean", - `-proj ${format === "topojson" ? "wgs84" : "somerc"}`, - `-o output.${format} format=${format} drop-table id-field=id`, - ].join("\n"); - - console.log("### Mapshaper commands ###"); - console.log(commands); - const output = await mapshaper.applyCommands(commands, input); - - if (format === "topojson") { - if (query.download !== undefined) { - res.setHeader( - "Content-Disposition", - `attachment; filename="swiss-maps.json"` - ); - } - - res.setHeader("Content-Type", "application/json"); - res.status(200).send(output["output.topojson"]); - } else if (format === "svg") { - res.setHeader("Content-Type", "image/svg+xml"); + const output = await generate({ + ...options, + year, + simplify: query.simplify as string, + shapes: [...shapes].sort(shapeIndexComparator), + mapshaperCommands: [ + `-o output.${format} format=${format} drop-table id-field=id target=*`, + ], + }); - if (query.download !== undefined) { - res.setHeader( - "Content-Disposition", - `attachment; filename="swiss-maps.svg"` - ); - } - res.status(200).send(output["output.svg"]); + if (query.download !== undefined) { + res.setHeader( + "Content-Disposition", + `attachment; filename="swiss-maps.${formatExtensions[format]}"` + ); } + + res.setHeader("Content-Type", formatContentTypes[format]); + res.status(200).send(output); } catch (e) { console.error(e); res.status(500).json({ message: "Internal error" }); diff --git a/website/src/pages/api/v0.ts b/website/src/pages/api/v0.ts index 1e700a6a..a0d13258 100644 --- a/website/src/pages/api/v0.ts +++ b/website/src/pages/api/v0.ts @@ -1,12 +1,16 @@ import Cors from "cors"; -import { either } from "fp-ts"; -import { promises as fs } from "fs"; -import { enableMapSet, produce } from "immer"; -import * as t from "io-ts"; -import * as mapshaper from "mapshaper"; +import { enableMapSet } from "immer"; import { NextApiRequest, NextApiResponse } from "next"; import * as path from "path"; -import { defaultOptions, Shape } from "src/shared"; +import { + formatContentTypes, + formatExtensions, + initMiddleware, + makeMapshaperStyleCommands, + parseOptions, + shapeIndexComparator, +} from "./_utils"; +import { generate } from "./_generate"; /** * Difference from `generate` api @@ -15,32 +19,12 @@ import { defaultOptions, Shape } from "src/shared"; enableMapSet(); -function initMiddleware(middleware: $FixMe) { - return (req: NextApiRequest, res: NextApiResponse) => - new Promise((resolve, reject) => { - middleware(req, res, (result: unknown) => { - if (result instanceof Error) { - return reject(result); - } - return resolve(result); - }); - }); -} - const cors = initMiddleware( Cors({ methods: ["GET", "POST", "OPTIONS"], }) ); -const Query = t.type({ - format: t.union([t.undefined, t.literal("topojson"), t.literal("svg")]), - year: t.union([t.undefined, t.string]), - shapes: t.union([t.undefined, t.string]), - simplify: t.union([t.undefined, t.string]), - download: t.union([t.undefined, t.string]), -}); - export default async function handler( req: NextApiRequest, res: NextApiResponse @@ -48,106 +32,63 @@ export default async function handler( try { await cors(req, res); - const query = either.getOrElseW(() => undefined)( - Query.decode(req.query) - ); + const { query } = req; + const options = parseOptions(req, res)!; + const { format, shapes, year } = options; - if (!query) { - res.statusCode = 200; - res.setHeader("Content-Type", "text/plain"); - res.end("Failed to decode query"); - return; + if (!formatExtensions[format]) { + res.status(500).json({ message: `Unsupported format ${format}` }); } - if (!query.shapes) { - res.setHeader("Content-Type", "text/plain"); - res.status(204).send("0"); - return; - } - - const options = produce(defaultOptions, (draft) => { - if (query.year) { - draft.year = query.year; - } - if (query.format) { - draft.format = query.format; - } - if (query.shapes) { - draft.shapes = new Set(query.shapes.split(",") as $FixMe); - } + const hasCantons = shapes.has("cantons"); + const hasMunicipalities = shapes.has("municipalities"); + const hasLakes = shapes.has("lakes"); + + const shapeStyles = { + country: { + fill: hasCantons || hasMunicipalities ? "transparent" : "#eee", + stroke: "#111", + }, + lakes: hasLakes + ? { + fill: "#a1d0f7", + } + : null, + cantons: hasCantons + ? { + fill: hasMunicipalities ? "transparent" : "#eee", + stroke: "#666", + } + : null, + municipalities: hasMunicipalities + ? { + fill: "#eee", + stroke: hasCantons ? "#bbb" : "#666", + } + : null, + }; + + const styleCommands = makeMapshaperStyleCommands(shapeStyles); + + const output = await generate({ + ...options, + simplify: query.simplify as string, + year, + shapes: [...shapes].sort(shapeIndexComparator), + mapshaperCommands: [ + ...styleCommands, + `-o output.${format} format=${format} target=*`, + ], }); - const { format, year, shapes } = options; - - const input = await (async () => { - const props = [...shapes].flatMap((shape) => { - return ["shp", "dbf", "prj"].map( - async (ext) => - [ - `${shape}.${ext}`, - await fs.readFile( - path.join( - process.cwd(), - "public/swiss-maps", - year, - `${shape}.${ext}` - ) - ), - ] as const - ); - }); - return Object.fromEntries(await Promise.all(props)); - })(); - - const inputFiles = [...shapes].map((shape) => `${shape}.shp`).join(" "); - - const commands = [ - `-i ${inputFiles} combine-files string-fields=*`, - query.simplify ? `-simplify ${query.simplify} keep-shapes` : "", - "-clean", - `-proj ${format === "topojson" ? "wgs84" : "somerc"}`, - // svg coloring, otherwise is all bblack - shapes.has("cantons") - ? `-style fill='#e6e6e6' stroke='#999' target='cantons'` - : "", - shapes.has("lakes") ? `-style fill='#a1d0f7' target='lakes'` : "", - `-o output.${format} format=${format} target=*`, - ].join("\n"); - - console.log("### Mapshaper commands ###"); - console.log(commands); - - const output = await mapshaper.applyCommands(commands, input); - - switch (format) { - case "topojson": { - if (query.download !== undefined) { - res.setHeader( - "Content-Disposition", - `attachment; filename="swiss-maps.json"` - ); - } - - res.setHeader("Content-Type", "application/json"); - res.status(200).send(output["output.topojson"]); - break; - } - - case "svg": { - res.setHeader("Content-Type", "image/svg+xml"); - if (query.download !== undefined) { - res.setHeader( - "Content-Disposition", - `attachment; filename="swiss-maps.svg"` - ); - } - res.status(200).send(output["output.svg"]); - break; - } - default: - res.status(500).json({ message: "Unsupported format" }); - break; + if (query.download !== undefined) { + res.setHeader( + "Content-Disposition", + `attachment; filename="swiss-maps.${formatExtensions[format]}"` + ); } + res.setHeader("Content-Type", formatContentTypes[format]); + res.status(200).send(output); } catch (e) { console.error(e); res.status(500).json({ message: "Internal error" });