jp-pref-icons
Version:
Generate beautiful icon images for Japanese prefectures with official government data
641 lines (542 loc) • 22.3 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const { createCanvas } = require('canvas');
const turf = require('@turf/turf');
const { NAMES_JA, ROMAJI } = require('./prefectures');
const puppeteer = require('puppeteer');
const https = require('https');
class PrefectureIconGenerator {
constructor(options = {}) {
this.options = {
size: options.size || 256,
lineWidth: options.lineWidth || 0.5,
faceColor: options.faceColor || '#0E7A6F',
edgeColor: options.edgeColor || '#0A5A52',
textColor: options.textColor || '#FFFFFF',
textSize: options.textSize || 0.12,
outputDir: options.outputDir || 'icons',
generateSVG: options.generateSVG || false,
padding: options.padding || 0.07,
targetPrefectures: options.targetPrefectures || null,
showText: options.showText === undefined ? true : options.showText // Default to true unless explicitly set to false
};
// Parse target prefectures if specified
if (this.options.targetPrefectures) {
this.targetPrefectures = this.parseTargetPrefectures(this.options.targetPrefectures);
}
}
parseTargetPrefectures(input) {
const targets = new Set();
const items = input.split(',').map(item => item.trim());
items.forEach(item => {
// Check if it's a number (prefecture code)
const code = parseInt(item);
if (!isNaN(code) && code >= 1 && code <= 47) {
targets.add(code);
} else {
// Try to match by Japanese name
const matchedCode = Object.entries(NAMES_JA).find(([, v]) => v === item)?.[0];
if (matchedCode) {
targets.add(parseInt(matchedCode));
} else {
// Try to match by Romaji name (case-insensitive)
const romajiMatch = Object.entries(ROMAJI).find(([, v]) =>
v.toLowerCase() === item.toLowerCase()
)?.[0];
if (romajiMatch) {
targets.add(parseInt(romajiMatch));
} else {
console.warn(`Warning: Could not find prefecture: ${item}`);
}
}
}
});
return targets.size > 0 ? targets : null;
}
async loadGeoJSON(filePath) {
const data = await fs.readJson(filePath);
return data;
}
findColumns(features) {
if (!features || features.length === 0) return { codeCol: null, nameCol: null };
const sample = features[0].properties;
const codeColumns = ['code', 'pref_code', 'jiscode', 'PREF_CODE', 'PREF'];
const nameColumns = ['nam_ja', 'name_ja', 'NAME_JA', 'name', 'NAME', 'pref_name', 'P'];
const codeCol = codeColumns.find(col => col in sample) || null;
const nameCol = nameColumns.find(col => col in sample) || null;
return { codeCol, nameCol };
}
groupByPrefecture(features, codeCol, nameCol) {
const groups = {};
features.forEach(feature => {
let key;
if (codeCol) {
key = parseInt(feature.properties[codeCol]);
} else if (nameCol) {
const name = feature.properties[nameCol];
// Try to find code from name
const code = Object.entries(NAMES_JA).find(([, v]) => v === name)?.[0];
key = code ? parseInt(code) : name;
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(feature);
});
return groups;
}
getSquareExtent(bbox, padding) {
const [minX, minY, maxX, maxY] = bbox;
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const r = Math.max(maxX - minX, maxY - minY) * (0.5 + padding);
return [cx - r, cy - r, cx + r, cy + r]; // [minX, minY, maxX, maxY] format
}
projectToCanvas(coords, extent, size) {
const [minX, minY, maxX, maxY] = extent; // Fixed order to match getSquareExtent
const width = maxX - minX;
const height = maxY - minY;
const x = ((coords[0] - minX) / width) * size;
const y = size - ((coords[1] - minY) / height) * size; // Flip Y axis
return [x, y];
}
filterTokyoMainland(features) {
if (features.length === 0) return features;
// If there's only one feature, it's likely a MultiPolygon containing all Tokyo parts
if (features.length === 1) {
const feature = features[0];
if (feature.geometry.type === 'MultiPolygon') {
// Extract individual polygons from MultiPolygon
const polygons = feature.geometry.coordinates.map((coords, index) => ({
type: 'Feature',
properties: { ...feature.properties, polygon_index: index },
geometry: {
type: 'Polygon',
coordinates: coords
}
}));
return this.filterTokyoMainlandPolygons(polygons);
}
}
// If multiple features, process them directly
return this.filterTokyoMainlandPolygons(features);
}
filterTokyoMainlandPolygons(polygons) {
// Calculate area and centroid for each polygon
const polygonsWithInfo = polygons.map((polygon, index) => {
const area = turf.area(polygon);
const centroid = turf.centroid(polygon);
const [longitude, latitude] = centroid.geometry.coordinates;
return { polygon, area, centroid, longitude, latitude, index };
});
// Sort by area (largest first)
polygonsWithInfo.sort((a, b) => b.area - a.area);
console.log(` Analyzing ${polygonsWithInfo.length} Tokyo polygons:`);
polygonsWithInfo.slice(0, 10).forEach((p, i) => {
console.log(` Polygon ${i}: lon=${p.longitude.toFixed(3)}, lat=${p.latitude.toFixed(3)}, area=${p.area.toFixed(0)}`);
});
// Tokyo mainland bounds (23 special wards + Tama area):
// Longitude: 139.0° - 139.9°E (excluding Izu Islands and Ogasawara)
// Latitude: 35.5° - 35.9°N (main Tokyo metropolitan area)
const mainlandBounds = {
minLon: 139.0,
maxLon: 139.9,
minLat: 35.5,
maxLat: 35.9
};
// Filter polygons within mainland bounds
const mainlandPolygons = polygonsWithInfo.filter(({ longitude, latitude }) => {
return longitude >= mainlandBounds.minLon &&
longitude <= mainlandBounds.maxLon &&
latitude >= mainlandBounds.minLat &&
latitude <= mainlandBounds.maxLat;
});
if (mainlandPolygons.length === 0) {
console.log(' Warning: No mainland polygons found, using largest polygon');
return [polygonsWithInfo[0].polygon];
}
console.log(` Tokyo mainland filter: ${polygonsWithInfo.length} -> ${mainlandPolygons.length} polygons (excluded islands)`);
// Create a new MultiPolygon feature with only mainland polygons
if (mainlandPolygons.length === 1) {
return [mainlandPolygons[0].polygon];
} else {
// Combine multiple mainland polygons into one MultiPolygon feature
const combinedFeature = {
type: 'Feature',
properties: polygons[0].properties,
geometry: {
type: 'MultiPolygon',
coordinates: mainlandPolygons.map(p => p.polygon.geometry.coordinates)
}
};
return [combinedFeature];
}
}
filterKagoshimaMainland(features) {
if (features.length === 0) return features;
// If there's only one feature, it's likely a MultiPolygon containing all Kagoshima parts
if (features.length === 1) {
const feature = features[0];
if (feature.geometry.type === 'MultiPolygon') {
// Extract individual polygons from MultiPolygon
const polygons = feature.geometry.coordinates.map((coords, index) => ({
type: 'Feature',
properties: { ...feature.properties, polygon_index: index },
geometry: {
type: 'Polygon',
coordinates: coords
}
}));
return this.filterKagoshimaMainlandPolygons(polygons);
}
}
// If multiple features, process them directly
return this.filterKagoshimaMainlandPolygons(features);
}
filterKagoshimaMainlandPolygons(polygons) {
// Calculate area and centroid for each polygon
const polygonsWithInfo = polygons.map((polygon, index) => {
const area = turf.area(polygon);
const centroid = turf.centroid(polygon);
const [longitude, latitude] = centroid.geometry.coordinates;
return { polygon, area, centroid, longitude, latitude, index };
});
// Sort by area (largest first)
polygonsWithInfo.sort((a, b) => b.area - a.area);
console.log(` Analyzing ${polygonsWithInfo.length} Kagoshima polygons:`);
polygonsWithInfo.slice(0, 10).forEach((p, i) => {
console.log(` Polygon ${i}: lon=${p.longitude.toFixed(3)}, lat=${p.latitude.toFixed(3)}, area=${p.area.toFixed(0)}`);
});
// Kagoshima mainland bounds (Satsuma and Osumi peninsulas):
// Longitude: 129.4° - 131.5°E (excluding distant islands like Amami, Yakushima, Tanegashima)
// Latitude: 30.8° - 32.2°N (main Kyushu part of Kagoshima)
const mainlandBounds = {
minLon: 129.4,
maxLon: 131.5,
minLat: 30.8,
maxLat: 32.2
};
// Filter polygons within mainland bounds
const mainlandPolygons = polygonsWithInfo.filter(({ longitude, latitude }) => {
return longitude >= mainlandBounds.minLon &&
longitude <= mainlandBounds.maxLon &&
latitude >= mainlandBounds.minLat &&
latitude <= mainlandBounds.maxLat;
});
if (mainlandPolygons.length === 0) {
console.log(' Warning: No mainland polygons found, using largest polygon');
return [polygonsWithInfo[0].polygon];
}
console.log(` Kagoshima mainland filter: ${polygonsWithInfo.length} -> ${mainlandPolygons.length} polygons (excluded islands)`);
// Create a new MultiPolygon feature with only mainland polygons
if (mainlandPolygons.length === 1) {
return [mainlandPolygons[0].polygon];
} else {
// Combine multiple mainland polygons into one MultiPolygon feature
const combinedFeature = {
type: 'Feature',
properties: polygons[0].properties,
geometry: {
type: 'MultiPolygon',
coordinates: mainlandPolygons.map(p => p.polygon.geometry.coordinates)
}
};
return [combinedFeature];
}
}
drawPolygonPath(ctx, coordinates, extent, size) {
// Draw polygon path without beginPath/closePath
const drawRing = (ring) => {
ring.forEach((coord, i) => {
const [x, y] = this.projectToCanvas(coord, extent, size);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
};
if (coordinates[0] && coordinates[0][0] && Array.isArray(coordinates[0][0])) {
// Polygon format
drawRing(coordinates[0]);
}
}
async generateIcon(features, prefCode, prefName, romaji) {
const size = this.options.size;
const canvas = createCanvas(size, size);
const ctx = canvas.getContext('2d');
// Special handling for Tokyo (code 13) and Kagoshima (code 46) - exclude islands
let featuresToProcess = features;
if (prefCode === 13) {
featuresToProcess = this.filterTokyoMainland(features);
console.log(` Filtered Tokyo: ${features.length} features -> ${featuresToProcess.length} mainland features`);
} else if (prefCode === 46) {
featuresToProcess = this.filterKagoshimaMainland(features);
console.log(` Filtered Kagoshima: ${features.length} features -> ${featuresToProcess.length} mainland features`);
}
// Combine all geometries
let combined;
if (featuresToProcess.length === 1) {
combined = featuresToProcess[0];
} else {
// Union all features
combined = featuresToProcess[0];
for (let i = 1; i < featuresToProcess.length; i++) {
try {
combined = turf.union(combined, featuresToProcess[i]);
} catch (e) {
// If union fails, just use the first feature
console.warn(`Warning: Could not union features for ${prefName}`);
}
}
}
// Get bounding box
const bbox = turf.bbox(combined);
const extent = this.getSquareExtent(bbox, this.options.padding);
// Set background to white
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, size, size);
// Set explicit fill and stroke styles
ctx.fillStyle = this.options.faceColor;
ctx.strokeStyle = this.options.edgeColor;
ctx.lineWidth = this.options.lineWidth * (size / 100);
ctx.globalCompositeOperation = 'source-over';
// Draw based on geometry type
const geom = combined.geometry;
if (geom.type === 'Polygon') {
ctx.beginPath();
this.drawPolygonPath(ctx, geom.coordinates, extent, size);
ctx.closePath();
ctx.fill();
ctx.stroke();
} else if (geom.type === 'MultiPolygon') {
// Draw all polygons
geom.coordinates.forEach(polygon => {
ctx.beginPath();
this.drawPolygonPath(ctx, polygon, extent, size);
ctx.closePath();
ctx.fill();
ctx.stroke();
});
}
// Draw text (if enabled)
if (this.options.showText) {
const center = turf.center(combined);
const [cx, cy] = this.projectToCanvas(center.geometry.coordinates, extent, size);
// Reset shadow settings from previous drawing
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
// Set text properties
ctx.font = `bold ${size * this.options.textSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Draw text stroke (outline) first
ctx.strokeStyle = this.options.edgeColor;
ctx.lineWidth = size * 0.06; // Outline thickness relative to icon size (increased for better visibility)
ctx.strokeText(prefName, cx, cy);
// Draw text fill on top of stroke
ctx.fillStyle = this.options.textColor;
ctx.fillText(prefName, cx, cy);
}
// Generate file name and ensure output directory
const fileName = prefCode ? `${String(prefCode).padStart(2, '0')}_${romaji}` : romaji;
const pngPath = path.join(this.options.outputDir, `${fileName}.png`);
await fs.ensureDir(this.options.outputDir);
// Generate SVG first, then convert to PNG using Puppeteer
const svgPath = await this.generateSVG(combined, extent, prefName, fileName);
// Convert SVG to PNG using Puppeteer
await this.convertSvgToPng(svgPath, pngPath, size);
// Remove SVG if not requested
if (!this.options.generateSVG) {
await fs.remove(svgPath);
}
return pngPath;
}
async generateSVG(feature, extent, prefName, fileName) {
const size = this.options.size;
const [minX, minY, maxX, maxY] = extent; // Fixed order
const width = maxX - minX;
const height = maxY - minY;
// Convert coordinates to SVG path
const coordsToPath = (coords) => {
return coords.map((point, i) => {
const x = ((point[0] - minX) / width) * size;
const y = size - ((point[1] - minY) / height) * size;
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
}).join(' ') + ' Z';
};
let pathData = '';
const geom = feature.geometry;
if (geom.type === 'Polygon') {
pathData = coordsToPath(geom.coordinates[0]);
} else if (geom.type === 'MultiPolygon') {
pathData = geom.coordinates.map(polygon => coordsToPath(polygon[0])).join(' ');
}
const center = turf.center(feature);
const [cx, cy] = this.projectToCanvas(center.geometry.coordinates, extent, size);
// Generate text element only if showText is enabled
const textElement = this.options.showText ? `
<text x="${cx}" y="${cy}" fill="${this.options.textColor}" stroke="${this.options.edgeColor}" stroke-width="${size * 0.008}" font-size="${size * this.options.textSize}" font-weight="bold" text-anchor="middle" dominant-baseline="middle" font-family="sans-serif">
${prefName}
</text>` : '';
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<path d="${pathData}" fill="${this.options.faceColor}" stroke="${this.options.edgeColor}" stroke-width="${this.options.lineWidth * (size / 100)}" />${textElement}
</svg>`;
const svgPath = path.join(this.options.outputDir, `${fileName}.svg`);
await fs.writeFile(svgPath, svg);
return svgPath;
}
async convertSvgToPng(svgPath, pngPath, size) {
try {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
// Set viewport size
await page.setViewport({ width: size, height: size });
// Read SVG content
const svgContent = await fs.readFile(svgPath, 'utf8');
// Create HTML with SVG and transparent background
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; background: transparent; }
svg { display: block; }
</style>
</head>
<body>
${svgContent}
</body>
</html>
`;
// Set HTML content
await page.setContent(html);
// Take screenshot with transparent background
await page.screenshot({
path: pngPath,
type: 'png',
omitBackground: true,
clip: { x: 0, y: 0, width: size, height: size }
});
await browser.close();
console.log(` Converted SVG to PNG: ${path.basename(pngPath)}`);
return pngPath;
} catch (error) {
console.error(`Error converting SVG to PNG: ${error.message}`);
throw error;
}
}
async downloadGeoJSONToMemory(url) {
console.log(`Downloading GeoJSON from: ${url}`);
return new Promise((resolve, reject) => {
https.get(url, (response) => {
if (response.statusCode === 200) {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
try {
const geoJson = JSON.parse(data);
console.log(`Downloaded GeoJSON data (${Math.round(data.length / 1024)}KB)`);
resolve(geoJson);
} catch (error) {
reject(new Error(`Failed to parse JSON: ${error.message}`));
}
});
} else if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirects
this.downloadGeoJSONToMemory(response.headers.location)
.then(resolve)
.catch(reject);
} else {
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
}
}).on('error', (err) => {
reject(err);
});
});
}
async ensureGeoJSONData() {
// Official source: dataofjapan/land repository (based on 国土地理院 data)
const sourceUrl = 'https://raw.githubusercontent.com/dataofjapan/land/master/japan.geojson';
try {
return await this.downloadGeoJSONToMemory(sourceUrl);
} catch (error) {
console.error('Failed to download official data:', error.message);
// Fallback to alternative source
console.log('Trying alternative source...');
const fallbackUrl = 'https://raw.githubusercontent.com/smartnews-smri/japan-topography/main/data/prefecture.geojson';
try {
return await this.downloadGeoJSONToMemory(fallbackUrl);
} catch (fallbackError) {
throw new Error(`Failed to download GeoJSON data: ${fallbackError.message}`);
}
}
}
async generate(geoJsonPath = null) {
let data;
// If no path provided, download official data to memory
if (!geoJsonPath) {
console.log('Loading GeoJSON data...');
data = await this.ensureGeoJSONData();
} else {
// Load from file path
console.log('Loading GeoJSON data...');
data = await this.loadGeoJSON(geoJsonPath);
}
const features = data.features;
const { codeCol, nameCol } = this.findColumns(features);
if (!codeCol && !nameCol) {
throw new Error('Could not find prefecture code or name column in GeoJSON');
}
console.log(`Found columns - Code: ${codeCol}, Name: ${nameCol}`);
const groups = this.groupByPrefecture(features, codeCol, nameCol);
const keys = Object.keys(groups).sort((a, b) => {
const aNum = parseInt(a);
const bNum = parseInt(b);
if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum;
return a.localeCompare(b);
});
// Filter prefectures if targets are specified
let filteredKeys = keys;
if (this.targetPrefectures) {
filteredKeys = keys.filter(key => {
const code = parseInt(key);
if (!isNaN(code)) {
return this.targetPrefectures.has(code);
} else {
// Try to match by name
const matchedCode = Object.entries(NAMES_JA).find(([, v]) => v === key)?.[0];
return matchedCode && this.targetPrefectures.has(parseInt(matchedCode));
}
});
if (filteredKeys.length === 0) {
console.log('No matching prefectures found for the specified targets.');
return;
}
console.log(`Generating icons for ${filteredKeys.length} specified prefecture(s)...`);
} else {
console.log(`Generating icons for ${filteredKeys.length} prefectures...`);
}
for (const key of filteredKeys) {
const features = groups[key];
let code, japaneseName, romajiName;
if (!isNaN(parseInt(key))) {
code = parseInt(key);
japaneseName = NAMES_JA[code] || `Prefecture ${code}`;
romajiName = ROMAJI[code] || `Pref${String(code).padStart(2, '0')}`;
} else {
japaneseName = key;
code = Object.entries(NAMES_JA).find(([, v]) => v === key)?.[0];
romajiName = code ? ROMAJI[code] : 'Unknown';
code = code ? parseInt(code) : 0;
}
console.log(` Generating: ${japaneseName} (${romajiName})`);
await this.generateIcon(features, code, japaneseName, romajiName);
}
console.log(`Done! Icons saved to ${this.options.outputDir}/`);
}
}
module.exports = PrefectureIconGenerator;