diff --git a/.eslintrc.js b/.eslintrc.js index b462405..b49435a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,6 +12,8 @@ module.exports = { ], rules: { 'vue/require-default-prop': 'off', - 'vue/multi-word-component-names': 'off' + 'vue/multi-word-component-names': 'off', + 'vue/attribute-order': 'off', + '@typescript-eslint/no-explicit-any': 'off' } } diff --git a/.gitignore b/.gitignore index 42bd71b..de14059 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist out .DS_Store *.log* +map-* diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 3909346..fe97cc9 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -5,8 +5,10 @@ + + @@ -16,8 +18,10 @@ + + @@ -28,7 +32,7 @@ - + @@ -36,7 +40,7 @@ - + @@ -44,7 +48,7 @@ - + @@ -52,7 +56,7 @@ - + diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 35893b3..5d3fb87 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -1,4 +1,4 @@ singleQuote: true semi: false -printWidth: 100 +printWidth: 220 trailingComma: none diff --git a/README.md b/README.md index 2a4b916..dfa4d85 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,11 @@ This standalone portable application allows downloading maps from various source 3. Show and resize the cropping area (use the control key to toggle between dragging the map or the crop area) 4. Download your map. - -![screenshot.gif](https://github.com/extic/map-downloader/blob/main/packages/renderer/public/screenshot.gif?raw=true) +![screenshot.gif](https://github.com/extic/map-downloader/blob/main/src/renderer/public/screenshot.gif?raw=true) ## Donate -If you like this application, I would very much appriciate your donation as it gives me the will to continue and improve it. Thank you! +If you like this application, I would very much appreciate your donation as it gives me the will to continue and improve it. Thank you! ## List of map sources - GovMap - https://www.govmap.gov.il diff --git a/package-lock.json b/package-lock.json index b355e4e..d8070fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "electron-fetch": "^1.9.1", "electron-updater": "^6.1.7", "pinia": "^2.2.0", "pureimage": "^0.4.13", @@ -4291,6 +4292,18 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-fetch": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/electron-fetch/-/electron-fetch-1.9.1.tgz", + "integrity": "sha512-M9qw6oUILGVrcENMSRRefE1MbHPIz0h79EKIeJWK9v563aT9Qkh8aEHPO1H5vi970wPirNY+jO9OpFoLiMsMGA==", + "license": "MIT", + "dependencies": { + "encoding": "^0.1.13" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/electron-publish": { "version": "24.13.1", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", @@ -4452,6 +4465,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -5599,7 +5621,6 @@ }, "node_modules/iconv-lite": { "version": "0.6.3", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6999,7 +7020,6 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { diff --git a/package.json b/package.json index 9c44444..321859c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "electron-fetch": "^1.9.1", "electron-updater": "^6.1.7", "pinia": "^2.2.0", "pureimage": "^0.4.13", diff --git a/src/common/maps/govmap.data.ts b/src/common/maps/govmap.data.ts index 9b6693a..6df5ab9 100644 --- a/src/common/maps/govmap.data.ts +++ b/src/common/maps/govmap.data.ts @@ -1,7 +1,4 @@ -// import * as pimage from "pureimage" -// import { Bitmap } from "pureimage" -// import { Readable } from "stream" -import { MapData, UrlResult, UrlUsageType } from "./map.data" +import { MapData, UrlResult, UrlUsageType } from './map.data' export const mapDataGovMap: MapData = { name: 'GovMap', @@ -10,7 +7,7 @@ export const mapDataGovMap: MapData = { if (mapType === '1:25000' && (zoomLevel < 5 || zoomLevel > 9)) { return { url: '', unsupported: true } } - const zoomLevelStr = (mapType === '1:25000' ? zoomLevel - 5 : zoomLevel).toString(10).padStart(2, "0") + const zoomLevelStr = (mapType === '1:25000' ? zoomLevel - 5 : zoomLevel).toString(10).padStart(2, '0') const rowStr = row.toString(16).padStart(8, '0') const colStr = col.toString(16).padStart(8, '0') // https://cdn.govmap.gov.il/B0BZ1ORTO23/L08/R00004987/C00004114.jpg @@ -20,7 +17,6 @@ export const mapDataGovMap: MapData = { const mapTypeStr = mapType === 'Satellite' ? 'B0BZ1ORTO23' : mapType === 'Street & Buildings' ? 'B0b3010BLDG' : '2024MAP25KTO' const suffix = mapType === 'Satellite' ? 'jpg' : 'png' const domain = 'cdn.govmap.gov.il' - console.log(`https://${domain}/${mapTypeStr}/L${zoomLevelStr}/R${rowStr}/C${colStr}.${suffix}`); return { url: `https://${domain}/${mapTypeStr}/L${zoomLevelStr}/R${rowStr}/C${colStr}.${suffix}` } @@ -41,12 +37,6 @@ export const mapDataGovMap: MapData = { return 2 }, - // decode: async (mapType: string, buffer: Buffer): Promise => { - // return await (mapType === 'Satellite' - // ? pimage.decodeJPEGFromStream(Readable.from(buffer)) - // : pimage.decodePNGFromStream(Readable.from(buffer))) - // }, - supportedMapTypes: ['Satellite', 'Street & Buildings', '1:25000'], showScale: true, diff --git a/src/common/maps/map.data.ts b/src/common/maps/map.data.ts index 9feef00..9af8de8 100644 --- a/src/common/maps/map.data.ts +++ b/src/common/maps/map.data.ts @@ -1,11 +1,4 @@ -// import { Bitmap } from "pureimage" -import { mapDataTelAviv } from './tel-aviv.data' -import { mapDataGalilTahton } from './galil-tahton.data' -import { mapDataHaifa } from './haifa.data' import { mapDataGovMap } from './govmap.data' -import { mapDataHodHasharon } from './hod-hasharon.data' -import { mapDataMapy } from './mapy.data' -import { mapDataNetanya } from './netanya.data' export type ZoomLayer = { readonly scale: number @@ -27,17 +20,10 @@ export type UrlResult = { export type MapData = { name: string - urlProvider: ( - usageType: UrlUsageType, - mapType: string, - zoomLevel: number, - row: number, - col: number - ) => Promise + urlProvider: (usageType: UrlUsageType, mapType: string, zoomLevel: number, row: number, col: number) => Promise getDownloaderHeaders?: () => any zoomLevelProvider: (zoomLevel: number) => string zoomFactorProvider: (zoomLevel: number, zoomIn: boolean) => number - // decode: (mapType: string, buffer: Buffer) => Promise supportedMapTypes: string[] showScale: boolean referer: string | undefined diff --git a/src/main/downloader.ts b/src/main/downloader.ts new file mode 100644 index 0000000..0ca575e --- /dev/null +++ b/src/main/downloader.ts @@ -0,0 +1,92 @@ +import * as pimage from 'pureimage' +import * as fs from 'fs' +import { BrowserWindow, shell } from 'electron' +import crypto from 'crypto' +import { DownloadData } from '../common/download' +import { MapData, maps, UrlResult, UrlUsageType } from '../common/maps/map.data' +import { getBackendData } from './maps/map-backend' + +export const downloadOptions = { + canceled: false +} + +export const downloadMap = async (win: BrowserWindow, request: DownloadData) => { + if (!request) { + return + } + + downloadOptions.canceled = false + + const maxX = request.endCol - request.startCol + 1 + const maxY = request.endRow - request.startRow + 1 + + console.log(`Starting download of ${maxX * maxY} tiles`) + + const map = maps.find((it) => it.name === request.mapName)! + + const croppedWidth = maxX * 256 - request.startX - (256 - request.endX) + const croppedHeight = maxY * 256 - request.startY - (256 - request.endY) + const overallImg = pimage.make(croppedWidth, croppedHeight) + const overallCtx = overallImg.getContext('2d') + + const backendData = getBackendData(map.name) + + for (let y = 0; y < maxY; y++) { + for (let x = 0; x < maxX; x++) { + if (downloadOptions.canceled) { + console.log('Download canceled') + return + } + + const progress = (x + y * maxX) / (maxX * maxY) + win.webContents.send('download-progress', progress) + + console.log(`Downloading images: ${(progress * 100).toFixed(2)}%, x=${x}/${maxX}, y=${y}/${maxY}`) + + const { url, unsupported } = await getTileUrl(map, request.zoomLevel, request.startRow + y, request.startCol + x, request.mapType) + try { + if (!unsupported) { + const headers = map.getDownloaderHeaders ? map.getDownloaderHeaders() : {} + + const response = await fetch(url, headers) //, { responseType: "arraybuffer" }); + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const img1 = await backendData.decode(request.mapType, buffer) + const ctx = img1.getContext('2d') + const imageData = ctx.getImageData(0, 0, 256, 256) + for (let j = 0; j < 256; j++) { + const posY = j + y * 256 - request.startY + if (posY >= 0) { + for (let i = 0; i < 256; i++) { + const posX = i + x * 256 - request.startX + if (posX >= 0) { + overallCtx.fillPixelWithColor(posX, posY, imageData.getPixelRGBA(i, j)) + } + } + } + } + } + } catch (error) { + console.log(error) + } + } + } + + console.log(`Downloading done`) + + const fileName = `map-${crypto.randomUUID()}.png` + pimage + .encodePNGToStream(overallImg, fs.createWriteStream(fileName)) + .then(() => { + win.webContents.send('download-done', true) + shell.openPath(fileName) + }) + .catch((e) => { + console.log('there was an error writing', e) + win.webContents.send('download-done', false) + }) +} + +const getTileUrl = async (map: MapData, zoomLevel: number, row: number, col: number, mapType: string): Promise => { + return await map.urlProvider(UrlUsageType.DOWNLOAD, mapType, zoomLevel, row, col) +} diff --git a/src/main/event-registrar.ts b/src/main/event-registrar.ts new file mode 100644 index 0000000..943d87c --- /dev/null +++ b/src/main/event-registrar.ts @@ -0,0 +1,26 @@ +import { App, BrowserWindow, ipcMain } from 'electron' +import { downloadMap, downloadOptions } from './downloader' +import { handleHttpRequest } from './http-request.service' +import { setReferrer } from './main-store' + +export const eventRegistrar = { + registerEvents: (win: BrowserWindow, app: App) => { + ipcMain.on('download-map', async (_, arg) => { + await downloadMap(win, arg) + }) + + ipcMain.on('cancel-download', () => { + downloadOptions.canceled = true + }) + + ipcMain.on('get-app-version', () => { + win.webContents.send('app-version', app.getVersion()) + }) + + ipcMain.on('set-referer', (_, arg) => { + setReferrer(arg) + }) + + ipcMain.handle('http:request', handleHttpRequest) + } +} diff --git a/src/main/http-request.service.ts b/src/main/http-request.service.ts new file mode 100644 index 0000000..002e380 --- /dev/null +++ b/src/main/http-request.service.ts @@ -0,0 +1,12 @@ +import { IpcMainInvokeEvent } from 'electron' +import fetch from 'electron-fetch' + +export async function handleHttpRequest(_: IpcMainInvokeEvent, url: string): Promise { + console.log('Getting', url) + const response = await fetch(url, { + headers: { + referer: 'https://en.mapy.cz/' + } + }) + return await response.arrayBuffer() +} diff --git a/src/main/index.ts b/src/main/index.ts index c9cb32b..7ac8885 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,8 @@ -import { app, shell, BrowserWindow, ipcMain } from 'electron' +import { app, BrowserWindow, shell } from 'electron' import { join } from 'path' -import { electronApp, optimizer, is } from '@electron-toolkit/utils' +import { electronApp, is, optimizer } from '@electron-toolkit/utils' import icon from '../../resources/favicon.ico?asset' +import { eventRegistrar } from './event-registrar' function createWindow(): void { // Create the browser window. @@ -19,6 +20,10 @@ function createWindow(): void { } }) + if (process.env.NODE_ENV !== "production") { + mainWindow.webContents.openDevTools() + } + mainWindow.on('ready-to-show', () => { mainWindow.show() }) @@ -35,6 +40,8 @@ function createWindow(): void { } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } + + eventRegistrar.registerEvents(mainWindow, app); } // This method will be called when Electron has finished @@ -52,7 +59,8 @@ app.whenReady().then(() => { }) // IPC test - ipcMain.on('ping', () => console.log('pong')) + // ipcMain.on('ping', () => console.log('pong')) + // ipcMain.on('download-map', (a) => console.log(a)) createWindow() diff --git a/src/main/main-store.ts b/src/main/main-store.ts new file mode 100644 index 0000000..72a653c --- /dev/null +++ b/src/main/main-store.ts @@ -0,0 +1,9 @@ +let currentReferer = '' + +export const setReferrer = (referer: string) => { + currentReferer = referer +} + +export const getReferrer = (): string => { + return currentReferer +} diff --git a/src/main/maps/govmap.backend.ts b/src/main/maps/govmap.backend.ts new file mode 100644 index 0000000..6d2c7bd --- /dev/null +++ b/src/main/maps/govmap.backend.ts @@ -0,0 +1,12 @@ +import * as pimage from 'pureimage' +import { Bitmap } from 'pureimage' +import { Readable } from 'stream' +import { MapBackendData } from './map-backend' + +export const mapBackendDataGovMap: MapBackendData = { + name: 'GovMap', + + decode: async (mapType: string, buffer: Buffer): Promise => { + return await (mapType === 'Satellite' ? pimage.decodeJPEGFromStream(Readable.from(buffer)) : pimage.decodePNGFromStream(Readable.from(buffer))) + } +} diff --git a/src/main/maps/map-backend.ts b/src/main/maps/map-backend.ts new file mode 100644 index 0000000..44738bd --- /dev/null +++ b/src/main/maps/map-backend.ts @@ -0,0 +1,21 @@ +import { mapBackendDataGovMap } from './govmap.backend' +import { Bitmap } from 'pureimage' + +export type MapBackendData = { + name: string + decode: (mapType: string, buffer: Buffer) => Promise +} + +const mapBackend = [ + mapBackendDataGovMap, + // mapDataGalilTahton, + // mapDataTelAviv, + // mapDataHaifa, + // mapDataHodHasharon, + // mapDataNetanya, + // mapDataMapy +] + +export function getBackendData(mapName: string) { + return mapBackend.find((it) => it.name === mapName)! +} diff --git a/src/renderer/public/screenshot.gif b/src/renderer/public/screenshot.gif new file mode 100644 index 0000000..f18b778 Binary files /dev/null and b/src/renderer/public/screenshot.gif differ diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 9180e27..98240ae 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -1,14 +1,3 @@ - - - - - - - diff --git a/src/renderer/src/assets/images/dialog-close-button.svg b/src/renderer/src/assets/images/dialog-close-button.svg new file mode 100644 index 0000000..91daff5 --- /dev/null +++ b/src/renderer/src/assets/images/dialog-close-button.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/src/assets/images/unsupported.png b/src/renderer/src/assets/images/unsupported.png new file mode 100644 index 0000000..edcb008 Binary files /dev/null and b/src/renderer/src/assets/images/unsupported.png differ diff --git a/src/renderer/src/components/ControlsPane.vue b/src/renderer/src/components/ControlsPane.vue index 8bb0ce5..3180001 100644 --- a/src/renderer/src/components/ControlsPane.vue +++ b/src/renderer/src/components/ControlsPane.vue @@ -43,20 +43,11 @@ + + diff --git a/src/renderer/src/components/dialog/download-dialog.vue b/src/renderer/src/components/dialog/download-dialog.vue new file mode 100644 index 0000000..4c43a6e --- /dev/null +++ b/src/renderer/src/components/dialog/download-dialog.vue @@ -0,0 +1,174 @@ + + + + {{ error ? 'Error' : 'Creating Map...' }} + + + + + + Progress + + + {{ progressValue() }}% + + + {{ error }} + + + {{ error ? 'Close' : 'Cancel' }} + + + + + + + + + diff --git a/src/renderer/src/components/dialog/use-dialog.ts b/src/renderer/src/components/dialog/use-dialog.ts new file mode 100644 index 0000000..b2f1124 --- /dev/null +++ b/src/renderer/src/components/dialog/use-dialog.ts @@ -0,0 +1,23 @@ +import { type Component, createVNode, getCurrentInstance, render } from 'vue' + +export function useDialog() { + const { appContext } = getCurrentInstance()! + + function show(dialog: Component, props: any) { + const dialogContainer = document.createElement('div') + document.body.appendChild(dialogContainer) + + const dialogInstance = createVNode(dialog, { + ...props, + onClose: () => { + render(null, dialogContainer) + dialogContainer.remove() + } + }) + + dialogInstance.appContext = appContext + render(dialogInstance, dialogContainer) + } + + return { show } +} diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index f6ebf3d..53134c3 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -1,12 +1,12 @@ import './assets/main.css' +import router from './router' +import { createPinia } from 'pinia' +import { mapDataGovMap } from '../../common/maps/govmap.data' +import { useMapStore } from './store/map-store' +import draggable from './components/draggable.directive' import { createApp } from 'vue' import App from './App.vue' -import router from "./router" -import { createPinia } from "pinia" -import { mapDataGovMap } from "../../common/maps/govmap.data" -import { useMapStore } from "./store/map-store"; -import draggable from "./components/draggable.directive"; export const pinia = createPinia() diff --git a/src/renderer/src/store/dialog-store.ts b/src/renderer/src/store/dialog-store.ts new file mode 100644 index 0000000..a9fc12e --- /dev/null +++ b/src/renderer/src/store/dialog-store.ts @@ -0,0 +1,12 @@ +// import { defineStore } from 'pinia' +// import { ref } from 'vue' +// +// export const useDialogStore = defineStore('dialog', () => { +// const veilZIndex = ref(0) +// +// function increaseVeilZIndex() { +// veilZIndex.value = veilZIndex.value + 2 +// } +// +// return { veilZIndex, increaseVeilZIndex } +// }) diff --git a/src/renderer/src/store/map-store.ts b/src/renderer/src/store/map-store.ts index 6addac7..de0b8c6 100644 --- a/src/renderer/src/store/map-store.ts +++ b/src/renderer/src/store/map-store.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { MapData, maps } from '../../../common/maps/map.data' import { DownloadData } from '../../../common/download' -import { ref } from "vue"; +import { ref } from 'vue' export type DragMode = 'map' | 'crop' @@ -34,7 +34,7 @@ export const useMapStore = defineStore('map', () => { const downloadData = ref(resetDownloadData()) const mapWidth = ref(0) const mapHeight = ref(0) - const tooLarge = ref(false) + // const tooLarge = ref(false) const appVersion = ref('') function setMap(mapData: MapData): void { @@ -88,7 +88,7 @@ export const useMapStore = defineStore('map', () => { downloadData, mapWidth, mapHeight, - tooLarge, + // tooLarge, appVersion, setMap, setMapDimensions, diff --git a/src/renderer/src/utils.ts b/src/renderer/src/utils.ts new file mode 100644 index 0000000..f273101 --- /dev/null +++ b/src/renderer/src/utils.ts @@ -0,0 +1,3 @@ +export function mod(n: number, m: number): number { + return ((n % m) + m) % m +} diff --git a/src/renderer/src/views/MapContainer.vue b/src/renderer/src/views/MapContainer.vue index 13e3b0a..7dff07b 100644 --- a/src/renderer/src/views/MapContainer.vue +++ b/src/renderer/src/views/MapContainer.vue @@ -6,27 +6,19 @@ diff --git a/tsconfig.node.json b/tsconfig.node.json index db23a68..8e148e3 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,6 +1,6 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"], + "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/common/**/*"], "compilerOptions": { "composite": true, "types": ["electron-vite/node"]