├── .bowerrc ├── .dockerignore ├── .gitignore ├── Dockerfile ├── Dockerfile.postgres ├── LICENSE ├── README.md ├── app.json ├── app ├── index.pug ├── routes.js ├── s3.js └── setup-check.js ├── bower.json ├── build └── migrate.sql ├── config-example.json ├── config.json ├── docker-compose.yml ├── package.json ├── public ├── directives │ ├── button-group.js │ ├── geojson-input.js │ ├── google-places.js │ ├── live-link.js │ ├── map-rough-preview.js │ ├── preview-link.js │ ├── published-check.js │ └── unique-slug.js ├── partials │ ├── aspect-ratio-selector.html │ ├── geojson-input.html │ ├── map-detail.html │ ├── map-list.html │ └── publish-modal.html ├── script.js └── style.css └── server.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "public/bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | .sass-cache/ 4 | bower_components/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8-alpine 2 | 3 | RUN apk add --no-cache tini 4 | 5 | EXPOSE 3001 6 | 7 | WORKDIR /usr/src/app 8 | 9 | # Copy just the package*.json files to perform the install 10 | COPY package* ./ 11 | RUN npm install 12 | 13 | # Then copy the remaining ones. Splitting the install from the main copy lets us cache the results of the install 14 | # between code changes 15 | COPY . . 16 | 17 | # Node wasn't designed to be run as PID 1. Tini is a tiny init wrapper. 18 | ENTRYPOINT ["/sbin/tini", "--"] 19 | 20 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /Dockerfile.postgres: -------------------------------------------------------------------------------- 1 | FROM postgres 2 | COPY ./build/migrate.sql /docker-entrypoint-initdb.d/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dow Jones 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Note: As of 2021, this tool has been deprecated and the repository is no longer being maintained. 2 | 3 | # Pinpoint Editor 4 | 5 | Pinpoint Editor is a web app for quickly creating and editing [Pinpoint maps](https://github.com/dowjones/pinpoint). 6 | 7 | **Features:** 8 | 9 | - Simple user interface allows maps to be created in seconds. 10 | - Flexible Angular app with Node backend. 11 | - Built-in support for uploading JSON data files to Amazon S3. 12 | 13 | ## How to set up Pinpoint Editor 14 | 15 | Pinpoint Editor requires: 16 | 17 | - A node.js server 18 | - A PostgresSQL database 19 | - NPM and Bower for installing dependencies 20 | - (optional) Amazon S3 to host data 21 | 22 | Here's how to install it locally: 23 | 24 | *Note: If you have trouble setting up Pinpoint Editor, please [open a ticket](https://github.com/dowjones/pinpoint-editor/issues/new) on GitHub.* 25 | 26 | 1. **Install required software** 27 | 28 | If on OS X, you can install all software using these commands: 29 | 30 | # Install Brew 31 | ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" 32 | 33 | # Install NodeJS. 34 | brew install node 35 | 36 | # Install PostgreSQL. 37 | brew install postgresql 38 | 39 | # Install Bower. 40 | npm install bower 41 | 42 | 2. **Set up database** 43 | 44 | Create a PostgresSQL database. You can name it anything you like. 45 | 46 | createdb pinpointDb 47 | 48 | Set `DATABASE_URL` environment variable. 49 | 50 | export DATABASE_URL='postgresql://localhost/pinpointDb' 51 | 52 | Run migration script to set up table and load examples. 53 | 54 | psql $DATABASE_URL < build/migrate.sql 55 | 56 | You may need to start the database server manually: 57 | 58 | pg_ctl -D /usr/local/var/postgres -l /usr/local/var/postgres/server.log start 59 | 60 | 3. **Install dependencies** 61 | 62 | # Install server-side dependencies 63 | npm install 64 | 65 | # Install client-side dependencies 66 | bower install 67 | 68 | 4. **Configure settings** 69 | 70 | Generate a new Google Maps API key by [following these instructions](https://developers.google.com/maps/documentation/javascript/tutorial) and add it to `config.json` (under the `googleMapsAPIKey` property). 71 | 72 | *Optional:* To enable AWS S3 export, set these environment variables: 73 | 74 | export AWS_S3_KEY='XXXXXXXXXXXXXX' 75 | export AWS_S3_SECRET='XXXXXXXXXXXXXX' 76 | export AWS_BUCKET='XXXXXXXXXXXXXX' 77 | 78 | 5. **Run the server!** 79 | 80 | node server.js 81 | 82 | You will then be able to access Pinpoint at [http://localhost:3001](http://localhost:3001/). 83 | 84 | ## Docker setup 85 | 86 | If you have Docker installed you can run the Pinpoint editor by simply typing `docker-compose up`. You can 87 | then access Pinpoint at [http://localhost:3001](http://localhost:3001/). 88 | 89 | ## Architecture 90 | 91 | On the server, Pinpoint uses the minimal Express framework for routing. Data is stored as JSON using [PostgresSQL's native JSON data type](http://schinckel.net/2014/05/25/querying-json-in-postgres/), which can then be accessed via a simple API (see below for details). Data can then be exported to S3-hosted static JSON for production use. 92 | 93 | On the client, Pinpoint is an Angular app made up of multiple custom directives. Key files are `script.js` and `directives/map-detail.html`. Dependencies are managed using Bower. 94 | 95 | ### API routes 96 | 97 | * Get all maps `GET - /api/maps` 98 | * Get map by id `GET - /api/maps/:id` 99 | * Create map `POST - /api/maps/` 100 | * http request header must be `Content-Type: application/json` 101 | * http request body contains the entire data object for that record 102 | * returns `HTTP/1.1 201 Created - {"id": id, "message": "Map created"}` 103 | * Update map `PUT - /api/maps/:id` 104 | * http request header must be `Content-Type: application/json` 105 | * http request body contains the entire data object for that record 106 | * returns `HTTP/1.1 200 OK - {"message": "Map updated"}` 107 | 108 | ## Configuration file 109 | 110 | Various settings are controlled via `config.json`. See `config-example.json` for dummy values. 111 | 112 | **Editor interface options** 113 | 114 | * googleMapsAPIKey: _(required)_ Google maps API key ([get one here](https://developers.google.com/maps/documentation/javascript/tutorial)) 115 | * title: Page title, e.g. _The Example Journal Map Tool_ 116 | * greeting: Message to go beneath page title. HTML is allowed. 117 | * helpLink: URL of an external help page 118 | * previewLink: URL which, with the current map's slug on the end, links to a preview 119 | * liveLink: URL which, with the current map's slug on the end, links to the live production page for the current map 120 | * s3url: URL which, with the current map's slug (and ".json") on the end, links to the S3-hosted static JSON 121 | * geojsonStyles: Array 122 | * Object 123 | * class: css class for style (string) - eg. "dashed-clockwise" 124 | * name: descriptive name for style (string) - eg. "Dashed, animated clockwise" 125 | 126 | **Map setting options** 127 | 128 | These are used in all Pinpoint instances in the editor. 129 | 130 | * basemap: Leaflet tilelayer URL (string) - eg. "http://{s}.somedomain.com/blabla/{z}/{x}/{y}.png" 131 | * basemapCredit: Credit line for tilelayer - eg. "Leaflet | © Mapbox | © OpenSteetMap contributors" 132 | 133 | 134 | ## Version history 135 | 136 | **v1.2.1** (27 March, 2017) 137 | 138 | - Bugfixes for editor interface 139 | 140 | **v1.2.0** (17 February, 2017) 141 | 142 | - New feature: basemap selection 143 | - Google maps API key controlled via config.json 144 | - Easier customisation of interface text via config.json 145 | - Add pagination to homepage 146 | 147 | **v1.1.0** (17 July, 2015) 148 | 149 | - Update bower.json to allow any 1.1.* versions of Pinpoint library 150 | - Add .bowerrc to fix bower_components location 151 | - Add helpful error message if server port is in use 152 | 153 | **v1.0.1** 154 | 155 | - Update bower.json to allow any 1.0.* versions of Pinpoint library 156 | 157 | **v1.0.0** 158 | 159 | - Initial release 160 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pinpoint Editor", 3 | "description": "Locator map tool", 4 | "success_url": "/", 5 | "scripts": { 6 | "start": "node server.js", 7 | "postdeploy": "psql $DATABASE_URL < build/migrate.sql" 8 | }, 9 | "env": { 10 | "AWS_BUCKET": { 11 | "value": "", 12 | "required": false, 13 | "description": "Name of AWS S3 bucket to upload date files to" 14 | }, 15 | "AWS_S3_KEY": { 16 | "value": "", 17 | "required": false, 18 | "description": "AWS key" 19 | }, 20 | "AWS_S3_SECRET": { 21 | "value": "", 22 | "required": false, 23 | "description": "AWS secret" 24 | } 25 | }, 26 | "addons": [ 27 | "heroku-postgresql:hobby-dev" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /app/index.pug: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #{title} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 |
21 |

Pinpoint Editor v#{version}

22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /app/routes.js: -------------------------------------------------------------------------------- 1 | var moment = require('moment'); 2 | var _ = require('lodash'); 3 | var fs = require('fs'); 4 | var config = require('../config'); 5 | config.version = require('../package.json').version; 6 | var pug = require('pug'); 7 | var s3 = require('./s3'); 8 | 9 | var pg = require('knex')({ 10 | client: 'pg', 11 | connection: process.env.DATABASE_URL 12 | }); 13 | 14 | module.exports = function(app) { 15 | 16 | var indexPage = pug.compileFile('./app/index.pug')(config); 17 | 18 | app.get('/', function(req, res, next) { 19 | res.send(indexPage); 20 | next(); 21 | }); 22 | 23 | app.get('/config.json', function(req, res) { 24 | config.s3enabled = s3.enabled(); 25 | config.basemaps = config.basemaps || []; 26 | res.json( config ); 27 | }); 28 | 29 | app.get('/_health', function (req, res) { 30 | res.send("I'm alive."); 31 | }); 32 | 33 | // API 34 | app.get('/api/maps', function(req, res) { 35 | pg('locator_map') 36 | .then(function(rows) { 37 | var rows = parse_data(rows); 38 | res.json(rows); 39 | }) 40 | .catch(function(error) { 41 | res.status(404).json({error: error}); 42 | }); 43 | }); 44 | 45 | app.get('/api/maps/slug/:slug', function(req, res) { 46 | var slug = req.params.slug; 47 | 48 | pg('locator_map') 49 | .whereRaw("data->>'slug' = ?", slug) 50 | .then(function(rows) { 51 | var rows = parse_data(rows); 52 | res.json(rows[0]); 53 | }) 54 | .catch(function(error) { 55 | res.status(404).json({error: error}); 56 | }); 57 | }); 58 | 59 | app.get('/api/maps/:id', function(req, res) { 60 | var id = req.params.id; 61 | 62 | pg('locator_map') 63 | .where('id', id) 64 | .then(function(rows) { 65 | var rows = parse_data(rows); 66 | res.json(rows[0]); 67 | }) 68 | .catch(function(error) { 69 | res.status(404).json({error: error}); 70 | }); 71 | }); 72 | 73 | app.post('/api/maps', function(req, res) { 74 | var data = req.body; 75 | data.creation_date = Date.now(); 76 | data.modification_date = Date.now(); 77 | 78 | pg.insert({data: data}) 79 | .returning('id') 80 | .into('locator_map') 81 | .then(function(id) { 82 | res.status(201).json({ 83 | id: id[0], 84 | message: 'Map created' 85 | }); 86 | }) 87 | .catch(function(error) { 88 | res.status(404).json({error: error}); 89 | }); 90 | }); 91 | 92 | app.put('/api/maps/:id', function(req, res) { 93 | var id = req.params.id; 94 | var data = req.body; 95 | delete data.id; 96 | data.modification_date = Date.now(); 97 | 98 | pg('locator_map') 99 | .where('id', '=', id) 100 | .update({ 101 | data: data 102 | }) 103 | .then(function() { 104 | res.json({ 105 | message: 'Map updated', 106 | }); 107 | }) 108 | .catch(function(error) { 109 | res.status(404).json({error: error}); 110 | }); 111 | }); 112 | 113 | app.delete('/api/maps/:id', function(req, res){ 114 | var id = req.params.id; 115 | var location = 'projects'; 116 | 117 | pg('locator_map') 118 | .where('id', id) 119 | .then(function(rows) { 120 | var slug = rows[0].data.slug; 121 | s3.remove(slug); 122 | return pg('locator_map').where('id', id).del(); 123 | }) 124 | .then(function(response) { 125 | res.json({ 126 | message: 'Map deleted' 127 | }); 128 | }) 129 | .catch(function(error) { 130 | res.status(404).json({error: error}); 131 | }); 132 | }); 133 | 134 | app.get('/api/slugs', function(req, res) { 135 | pg('locator_map') 136 | .then(function(rows) { 137 | var rows = parse_data(rows); 138 | var slugs = _.pluck(rows, 'slug'); 139 | res.json(slugs); 140 | }) 141 | .catch(function(error) { 142 | res.json({error: error}); 143 | }); 144 | }); 145 | 146 | app.post('/api/publish', function(req, res) { 147 | var id = req.body.id; 148 | var location = 'projects'; 149 | process_publish(id, location, res); 150 | }); 151 | 152 | } 153 | 154 | function parse_data(data){ 155 | var rows = _.map(data, function(row){ 156 | var map = { 157 | id: row.id 158 | }; 159 | return _.assign(map, row.data); 160 | }); 161 | return rows; 162 | } 163 | 164 | function process_publish(id, location, res){ 165 | pg('locator_map') 166 | .where('id', id) 167 | .then(function(rows) { 168 | var rows = parse_data(rows); 169 | var slug = rows[0].slug; 170 | var file = location + '/locator-maps/' + slug + '.json'; 171 | 172 | s3.upload(slug, rows[0]) 173 | .then(() => { 174 | res.json({ 175 | message : "Map published", 176 | url: 'http://'+process.env.AWS_BUCKET+'.s3.amazonaws.com/' + file 177 | }); 178 | }) 179 | .catch(() => { 180 | res.status(404).json({error: "Error saving map to S3"}); 181 | }); 182 | }) 183 | .catch(function(error) { 184 | res.json({error: error}); 185 | }); 186 | } -------------------------------------------------------------------------------- /app/s3.js: -------------------------------------------------------------------------------- 1 | var knox = require('knox'); 2 | 3 | if (process.env.AWS_S3_KEY) { 4 | var s3 = knox.createClient({ 5 | key: process.env.AWS_S3_KEY, 6 | secret: process.env.AWS_S3_SECRET, 7 | bucket: process.env.AWS_BUCKET 8 | }); 9 | } 10 | 11 | var upload = (slug, json) => { 12 | 13 | var string = JSON.stringify(json); 14 | 15 | return new Promise((res, rej) => { 16 | 17 | var publish = s3.put(file, { 18 | 'Content-Length': Buffer.byteLength(string), 19 | 'Content-Type': 'application/json', 20 | 'x-amz-acl': 'public-read' 21 | }); 22 | 23 | publish.on('response', function(response){ 24 | if (200 == response.statusCode) { 25 | res(); 26 | } else { 27 | rej(); 28 | } 29 | }); 30 | 31 | publish.end(string); 32 | 33 | }); 34 | 35 | } 36 | 37 | var remove = (slug) => { 38 | 39 | var path = location + '/locator-maps/' + slug + '.json'; 40 | 41 | return new Promise((res, rej) => { 42 | s3.del(path) 43 | .on('response', function(){ 44 | res(); 45 | }).end(); 46 | }); 47 | 48 | } 49 | 50 | var enabled = () => (!!process.env.AWS_S3_KEY); 51 | 52 | 53 | module.exports = { upload: upload, remove: remove, enabled: enabled }; -------------------------------------------------------------------------------- /app/setup-check.js: -------------------------------------------------------------------------------- 1 | module.exports = function( callback ){ 2 | var fs = require('fs'); 3 | var configStr = fs.readFileSync('config.json', 'utf8'); 4 | if (!testJSON(configStr)) { 5 | throw('Error: config.json is missing or invalid.'); 6 | } 7 | 8 | var config = JSON.parse(configStr); 9 | 10 | if ( 11 | (config.googleMapsAPIKey === 'REPLACE WITH API KEY') || 12 | (config.googleMapsAPIKey === undefined) 13 | ) { 14 | throw('Error: You need to add a Google Maps API key to config.json'); 15 | } 16 | 17 | if (!process.env.DATABASE_URL) { 18 | throw('Error: Database URL environment variable ("DATABASE_URL") is not set.') 19 | } 20 | 21 | callback(); 22 | 23 | function testJSON(str){ 24 | try { 25 | JSON.parse(str); 26 | } catch (err) { 27 | return false; 28 | } 29 | return true; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pinpoint-editor", 3 | "version": "1.1.0", 4 | "authors": [ 5 | "ejb " 6 | ], 7 | "license": "MIT", 8 | "ignore": [ 9 | "**/.*", 10 | "node_modules", 11 | "bower_components", 12 | "test", 13 | "tests", 14 | "public/bower_components/" 15 | ], 16 | "dependencies": { 17 | "pinpoint": "~1.1.0", 18 | "angular": "~1.3.13", 19 | "bootstrap": "~3.3.2", 20 | "leaflet": "~0.7.3", 21 | "jquery": "~2.1.3", 22 | "leaflet-minimap": "~2.1.0", 23 | "angular-route": "~1.3.13" 24 | }, 25 | "devDependencies": { 26 | "angular-mocks": "~1.3.13" 27 | }, 28 | "resolutions": { 29 | "angular": "~1.3.13" 30 | }, 31 | "description": "A web app for creating and editing Pinpoint maps.", 32 | "main": "server.js" 33 | } 34 | -------------------------------------------------------------------------------- /build/migrate.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- PostgreSQL database dump 3 | -- 4 | 5 | SET statement_timeout = 0; 6 | SET lock_timeout = 0; 7 | SET client_encoding = 'UTF8'; 8 | SET standard_conforming_strings = on; 9 | SET check_function_bodies = false; 10 | SET client_min_messages = warning; 11 | 12 | SET search_path = public, pg_catalog; 13 | 14 | SET default_tablespace = ''; 15 | 16 | SET default_with_oids = false; 17 | 18 | -- 19 | -- Name: locator_map; Type: TABLE; Schema: public; Owner: -; Tablespace: 20 | -- 21 | 22 | CREATE TABLE locator_map ( 23 | id bigint NOT NULL, 24 | data json 25 | ); 26 | 27 | 28 | -- 29 | -- Name: locator_map_id_seq; Type: SEQUENCE; Schema: public; Owner: - 30 | -- 31 | 32 | CREATE SEQUENCE locator_map_id_seq 33 | START WITH 1 34 | INCREMENT BY 1 35 | NO MINVALUE 36 | NO MAXVALUE 37 | CACHE 1; 38 | 39 | 40 | -- 41 | -- Name: locator_map_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 42 | -- 43 | 44 | ALTER SEQUENCE locator_map_id_seq OWNED BY locator_map.id; 45 | 46 | 47 | -- 48 | -- Name: id; Type: DEFAULT; Schema: public; Owner: - 49 | -- 50 | 51 | ALTER TABLE ONLY locator_map ALTER COLUMN id SET DEFAULT nextval('locator_map_id_seq'::regclass); 52 | 53 | 54 | -- 55 | -- Data for Name: locator_map; Type: TABLE DATA; Schema: public; Owner: - 56 | -- 57 | 58 | COPY locator_map (id, data) FROM stdin; 59 | 1 {"slug": "the-news-building", "aspect-ratio": "tall", "dek": "This is the dek", "hed": "Test Hed", "lat": 51.505864, "lon": -0.087765, "minimap": true, "zoom": 15, "markers": [{"lat": 51.505864, "lon": -0.087765, "text": "The News Building", "icon": "square", "label": "callout", "label-direction": "north"}], "creation_date":1423845091336,"modification_date":1423845091336} 60 | \. 61 | 62 | 63 | -- 64 | -- Name: locator_map_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - 65 | -- 66 | 67 | SELECT pg_catalog.setval('locator_map_id_seq', 11, true); 68 | 69 | 70 | -- 71 | -- Name: locator_map_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: 72 | -- 73 | 74 | ALTER TABLE ONLY locator_map 75 | ADD CONSTRAINT locator_map_pkey PRIMARY KEY (id); 76 | 77 | 78 | -- 79 | -- PostgreSQL database dump complete 80 | -- 81 | 82 | -------------------------------------------------------------------------------- /config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "geojsonStyles": [ 3 | { 4 | "class": "dashed", 5 | "name": "Dashed" 6 | }, 7 | { 8 | "class": "dashed-clockwise", 9 | "name": "Dashed, animated clockwise" 10 | }, 11 | { 12 | "class": "dashed-anticlockwise", 13 | "name": "Dashed, animated anticlockwise" 14 | } 15 | ], 16 | "previewLink": "http://preview-site.com/", 17 | "helpLink": "http://github.com/dowjones/pinpoint-editor", 18 | "liveLink": "http://production-site.com/", 19 | "s3url": "https://my-bucket.s3.amazonaws.com/projects/locator-maps/", 20 | "basemaps": [ 21 | { 22 | "name": "An example basemap", 23 | "url": "https://{s}.tiles.mapbox.com/v3/mybasemap/{z}/{x}/{y}.png", 24 | "credit": "Leaflet | © Mapbox | © OpenStreetMap" 25 | } 26 | ], 27 | "googleMapsAPIKey": "API_KEY_GOES_HERE" 28 | } -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "geojsonStyles": [ 3 | { 4 | "class": "dashed", 5 | "name": "Dashed" 6 | }, 7 | { 8 | "class": "dashed-clockwise", 9 | "name": "Dashed, animated clockwise" 10 | }, 11 | { 12 | "class": "dashed-anticlockwise", 13 | "name": "Dashed, animated anticlockwise" 14 | } 15 | ], 16 | "title": "Pinpoint Editor", 17 | "googleMapsAPIKey": "REPLACE WITH API KEY" 18 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | build: 5 | context: . 6 | dockerfile: ./Dockerfile.postgres 7 | pinpoint: 8 | build: . 9 | environment: 10 | - DATABASE_URL=postgresql://postgres@postgres/postgres 11 | ports: 12 | - "3001:3001" 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pinpoint-editor", 3 | "version": "1.2.1", 4 | "description": "A web app for creating and editing Pinpoint maps.", 5 | "main": "server.js", 6 | "author": "ejb ", 7 | "scripts": { 8 | "start": "node server.js", 9 | "postinstall": "bower install" 10 | }, 11 | "dependencies": { 12 | "body-parser": "~1.5.2", 13 | "express": "~4.7.2", 14 | "knex": "^0.6.22", 15 | "knox": "^0.9.2", 16 | "lodash": "^3.1.0", 17 | "moment": "^2.9.0", 18 | "morgan": "~1.2.2", 19 | "pg": "^4.2.0", 20 | "pug": "^2.0.0-beta10" 21 | }, 22 | "devDependencies": { 23 | "bower": "^1.8.4", 24 | "jasmine-core": "~2.2.0", 25 | "karma": "~0.12.31", 26 | "karma-chrome-launcher": "~0.1.7", 27 | "karma-jasmine": "~0.3.5", 28 | "karma-phantomjs-launcher": "~0.1.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public/directives/button-group.js: -------------------------------------------------------------------------------- 1 | pinpointTool.directive('buttonGroup', function(){ 2 | return { 3 | restrict:'E', 4 | replace:true, 5 | // transclude:true, 6 | scope: { 7 | value:'=', 8 | options:'=', 9 | labels:'=?' 10 | }, 11 | template: '
', 12 | link: function($scope, elm, attrs){ 13 | var $elm = $(elm); 14 | $.each($scope.options, function(i, o){ 15 | if ($scope.labels && $scope.labels[i]) { 16 | var label = $scope.labels[i]; 17 | } else { 18 | var label = o; 19 | } 20 | $elm.append(''); 21 | }); 22 | $elm.find('.btn[data-val="'+$scope.value+'"]').addClass('active'); 23 | $elm.find('.btn').click(function(){ 24 | var $this = $(this); 25 | $elm.find('.btn').removeClass('active'); 26 | $this.addClass('active'); 27 | var val = $this.attr('data-val'); 28 | $scope.value = val; 29 | $scope.$apply(); 30 | setTimeout(function() { 31 | $scope.$apply(); 32 | },50); 33 | }); 34 | } 35 | } 36 | }); -------------------------------------------------------------------------------- /public/directives/geojson-input.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | angular 3 | .module('pinpointTool') 4 | .directive('geojsonInput', geojsonInput); 5 | 6 | geojsonInput.$inject = ['configService']; 7 | function geojsonInput(configService){ 8 | return { 9 | restrict: 'E', 10 | templateUrl: 'partials/geojson-input.html', 11 | scope: { 12 | geojson:'=?' 13 | }, 14 | link: function ($scope, element, attrs) { 15 | // configure styles 16 | $scope.geojsonStyles = configService.geojsonStyles; 17 | $scope.geojsonStyles.unshift({ 18 | name: 'Default', 19 | class: '' 20 | }); 21 | 22 | // load in existing geojson 23 | $scope.geojsonRaw = []; 24 | if ($scope.geojson && $scope.geojson.features.length > 0) { 25 | for (var i = 0; i < $scope.geojson.features.length; i++) { 26 | var feature = $scope.geojson.features[i]; 27 | $scope.geojsonRaw[i] = { 28 | valid: true, 29 | style: feature.properties.pinpointStyle, 30 | value: JSON.stringify( feature ) 31 | }; 32 | } 33 | } 34 | $scope.geojson = { 35 | type: 'FeatureCollection', 36 | features: [] 37 | }; 38 | 39 | // add/remove geojsonRaw items 40 | $scope.removeFeature = function(feature){ 41 | var index = $scope.geojsonRaw.indexOf(feature); 42 | if (index > -1) { 43 | $scope.geojsonRaw.splice(index, 1); 44 | } 45 | } 46 | $scope.addFeature = function(){ 47 | $scope.geojsonRaw.push({ 48 | value: '', 49 | style: '', 50 | valid: true 51 | }); 52 | } 53 | 54 | // validate geojsonRaw 55 | // and pass it back to geojson 56 | $scope.$watch(function(){ 57 | var geojsonRaw = $scope.geojsonRaw; 58 | for (var i = 0; i < geojsonRaw.length; i++) { 59 | var valid = true; 60 | try { 61 | var parsed = JSON.parse(geojsonRaw[i].value); 62 | if (!parsed.properties || !parsed.properties.pinpointStyle) { 63 | parsed.properties = { 64 | pinpointStyle: geojsonRaw[i].style 65 | }; 66 | } else { 67 | parsed.properties.pinpointStyle = geojsonRaw[i].style; 68 | } 69 | $scope.geojson.features[i] = parsed; 70 | geojsonRaw[i].valid = true; 71 | } catch (err) { 72 | geojsonRaw[i].valid = false; 73 | } 74 | } 75 | }); // $scope.$watch 76 | 77 | } // link 78 | }; 79 | } 80 | })(); -------------------------------------------------------------------------------- /public/directives/google-places.js: -------------------------------------------------------------------------------- 1 | pinpointTool.directive('googlePlaces', function(){ 2 | return { 3 | restrict:'E', 4 | replace:true, 5 | // transclude:true, 6 | scope: { 7 | location:'=', 8 | locationName:'=?', 9 | placeholder:'@' 10 | }, 11 | template: '', 12 | link: function($scope, elm, attrs){ 13 | var autocomplete = new google.maps.places.Autocomplete(elm[0], {}); 14 | google.maps.event.addListener(autocomplete, 'place_changed', function() { 15 | var place = autocomplete.getPlace(); 16 | $scope.location = place.geometry.location.lat() + ',' + place.geometry.location.lng(); 17 | $scope.locationName = place.name; 18 | $scope.$apply(); 19 | elm.val(''); // clear text 20 | }); 21 | } 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /public/directives/live-link.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | angular 3 | .module('pinpointTool') 4 | .directive('liveLink', liveLink); 5 | 6 | liveLink.$inject = ['$http', 'configService']; 7 | function liveLink($http, configService){ 8 | return { 9 | restrict:'A', 10 | replace:false, 11 | scope: { 12 | published:'=?' 13 | }, 14 | link: function($scope, elm, attrs){ 15 | function disable(){ 16 | $(elm).attr('disabled', true); 17 | $(elm).text('Unpublished'); 18 | } 19 | function enable(){ 20 | $(elm).attr('disabled', false); 21 | $(elm).text('Live link'); 22 | } 23 | var url = configService.liveLink + attrs.slug; 24 | $(elm).attr('href',url).attr('target','_blank'); 25 | if (configService.s3url) { 26 | var ajax_url = configService.s3url + attrs.slug + '.json'; 27 | $http.get(ajax_url).error(disable); 28 | } else { 29 | enable(); 30 | } 31 | $scope.$watch(function(){ 32 | if ($scope.published === true) { 33 | enable(); 34 | } 35 | }); 36 | } 37 | } 38 | } 39 | 40 | })(); -------------------------------------------------------------------------------- /public/directives/map-rough-preview.js: -------------------------------------------------------------------------------- 1 | pinpointTool.directive('mapRoughPreview', ['configService', function(configService){ 2 | return { 3 | restrict:'E', 4 | replace:false, 5 | template: '
', 6 | link: function($scope, elm, attrs){ 7 | var mapOptions = { 8 | scrollWheelZoom: false, 9 | keyboard: false, 10 | attributionControl: false, 11 | zoomControl: false 12 | }; 13 | var mapEl = $(elm).find('.prevmap')[0]; 14 | var map = L.map(mapEl, mapOptions) 15 | .setView([attrs.lat, attrs.lon], attrs.zoom-1); 16 | 17 | var basemap = attrs.basemap; 18 | if (!basemap && (configService.basemaps.length > 0)) { 19 | basemap = $scope.config.basemaps[0].url; 20 | } else if (!basemap) { 21 | basemap = 'http://{s}.tile.osm.org/{z}/{x}/{y}.png'; 22 | } 23 | L.tileLayer( basemap ).addTo(map); 24 | } 25 | } 26 | }]); 27 | -------------------------------------------------------------------------------- /public/directives/preview-link.js: -------------------------------------------------------------------------------- 1 | pinpointTool.directive('previewLink', ['configService', function(configService){ 2 | return { 3 | restrict:'A', 4 | replace:false, 5 | link: function($scope, elm, attrs){ 6 | if (!configService.previewLink) { 7 | return $(elm).hide(); 8 | } 9 | var layout = 'margin'; 10 | var url = configService.previewLink + attrs.slug; 11 | $(elm).attr('href',url).attr('target','_blank'); 12 | } 13 | } 14 | }]); 15 | -------------------------------------------------------------------------------- /public/directives/published-check.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | angular 3 | .module('pinpointTool') 4 | .directive('publishedCheck', publishedCheck); 5 | 6 | publishedCheck.$inject = ['$http', 'configService']; 7 | function publishedCheck($http, configService){ 8 | return { 9 | restrict:'A', 10 | replace:false, 11 | link: function($scope, elm, attrs){ 12 | function disable(){ 13 | $(elm).html('unpublished'); 14 | } 15 | function enable(){ 16 | $(elm) 17 | .html(' published') 18 | .removeClass('label-default') 19 | .addClass('label-primary'); 20 | } 21 | if (configService.s3url) { 22 | var ajax_url = configService.s3url + attrs.slug + '.json'; 23 | $http.get(ajax_url).success(enable).error(disable); 24 | } else { 25 | enable(); 26 | } 27 | } 28 | } 29 | } 30 | 31 | })(); -------------------------------------------------------------------------------- /public/directives/unique-slug.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | angular 3 | .module('pinpointTool') 4 | .directive('uniqueSlug', uniqueSlug); 5 | 6 | uniqueSlug.$inject = ['$http']; 7 | function uniqueSlug($http){ 8 | return { 9 | restrict: 'A', 10 | require: 'ngModel', 11 | link: function ($scope, element, attrs, ngModel) { 12 | 13 | if ($scope.state === 'update') return; 14 | 15 | function validate(value) { 16 | if ($scope.slug === '') { 17 | ngModel.$setValidity('unique', false); 18 | } 19 | if ($scope.map.id || ($scope.slug && $scope.slug.indexOf(ngModel.$viewValue) === -1)) { 20 | ngModel.$setValidity('unique', true); 21 | } else { 22 | ngModel.$setValidity('unique', false); 23 | } 24 | } 25 | 26 | $http.get('/api/slugs').success( function (slugs) { 27 | $scope.slug = slugs; 28 | validate(ngModel.$viewValue); 29 | }); 30 | 31 | $scope.$watch( function() { 32 | return ngModel.$viewValue; 33 | }, validate); 34 | } 35 | }; 36 | } 37 | })(); -------------------------------------------------------------------------------- /public/partials/aspect-ratio-selector.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
-------------------------------------------------------------------------------- /public/partials/geojson-input.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
GeoJSON features
4 |
5 | 6 |

For advanced users only: Paste GeoJSON features below to include them on map. Point, MultiPoint and FeatureCollection types are not allowed. GeoJSON.io is a good editor.

7 | 8 |
9 | 10 |
11 | GeoJSON feature {{i+1}} 12 | 13 | 14 |
15 |
16 | 17 |
18 | 19 | 20 | 23 | 24 |
25 |
26 | 27 | 28 | 29 | 30 |

Invalid geoJSON

31 |
32 | 33 |
34 |
35 | 36 | 37 | 38 | 39 |
-------------------------------------------------------------------------------- /public/partials/map-detail.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 18 | 19 | 20 |
21 | 22 | 34 |
35 | 36 |
37 |
38 | 39 |
40 | 41 | 42 |
43 |
Quickstart
44 |
45 | 46 |

Search for a location to center the map and add a marker:

47 | 48 |
49 | 50 | 51 |

52 | There are already five markers on the map. Delete a marker (from the list at the bottom of this page) to add a new one. 53 |

54 | 55 |
56 | 57 |

Added {{quickstartName}} to the map. Now scroll down to customise the map and marker styles.

58 | 59 |
60 |
61 | 62 |
63 |
65 | 66 | 73 |

That slug is already in use. Please enter a different one.

74 |

75 | Invalid slug. Must be lowercase with dashes (eg. "london-bridge-map"). 76 |

77 | 78 |
79 | 80 |
81 | 82 | 83 |
84 | 85 |
86 | 87 | 88 |
89 | 90 |
91 | 92 | 93 |
94 | 95 |
96 |
97 | 98 |
99 | 100 |
101 | 102 |
103 | 104 | 105 |
106 | 107 |
108 | 109 | 110 | 111 |
112 | 113 | 114 |
115 | 116 | 117 |
118 | 119 |
120 | 121 | 122 | 123 |
124 | 125 | 126 | 127 |
128 |
129 |
130 | 131 | 132 |
133 | 136 |
137 | 138 |
139 | 140 | 141 | {{ map.zoom + map['minimap-zoom-offset'] }} 142 |
143 | 144 |
145 | 146 |
147 |
Markers
148 |
149 | 150 | 151 |
152 | 153 |
154 | Marker #{{i+1}} 155 | 156 | 157 |
158 | 159 | 160 |
161 | 162 |
163 | 164 |
165 | 166 | 167 |
168 | 169 | 170 |
171 | 172 | 173 |
174 | 175 | 176 |
177 | 178 | 179 |
180 | 181 | 182 |
183 | 184 | 185 |
186 | 187 | 188 |
189 | 190 |
191 | 192 | 193 |
194 | 195 | 196 |
197 | 198 |
199 | 200 | 201 | 202 | 203 |
204 | 205 | 206 | 207 | 208 |
209 | 210 |
Delete this map
211 | 212 | 213 | 214 |
215 |
216 | 217 | 218 | 219 | 234 | 235 | 236 | 237 | 238 | -------------------------------------------------------------------------------- /public/partials/map-list.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 12 | 13 |
14 | 15 | 38 | 39 |
Load more
40 |
Switch to list view
41 | 42 |
43 | 44 |
45 | 46 |

Create new map

47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
SlugHedLast updated
{{map.slug}}{{map.hed}}{{map.modification_date | date:"MM/dd/yyyy 'at' h:mma"}}PreviewLive link
67 | 68 |
Switch to expanded view
69 | 70 |
71 | 72 | 73 | 74 |
-------------------------------------------------------------------------------- /public/partials/publish-modal.html: -------------------------------------------------------------------------------- 1 | 29 | 30 | -------------------------------------------------------------------------------- /public/script.js: -------------------------------------------------------------------------------- 1 | var pinpointTool = angular.module('pinpointTool', ['ngRoute']); 2 | 3 | pinpointTool.provider('configService', function () { 4 | var options = {}; 5 | this.config = function (opt) { 6 | angular.extend(options, opt); 7 | }; 8 | this.$get = [function () { 9 | if (!options) { 10 | throw new Error('Config options must be configured'); 11 | } 12 | return options; 13 | }]; 14 | }) 15 | 16 | angular.element(document).ready(function () { 17 | $.get('/config.json', function (data) { 18 | 19 | angular.module('pinpointTool').config(['configServiceProvider', function (configServiceProvider) { 20 | configServiceProvider.config(data); 21 | }]); 22 | 23 | angular.bootstrap(document, ['pinpointTool']); 24 | }); 25 | }); 26 | 27 | pinpointTool.config(['$routeProvider', 28 | function($routeProvider) { 29 | $routeProvider. 30 | when('/maps', { 31 | templateUrl: 'partials/map-list.html', 32 | controller: 'mapListCtrl' 33 | }). 34 | when('/maps/:mapId', { 35 | templateUrl: 'partials/map-detail.html', 36 | controller: 'mapDetailCtrl' 37 | }). 38 | when('/maps/new', { 39 | templateUrl: 'partials/map-detail.html', 40 | controller: 'mapDetailCtrl' 41 | }). 42 | otherwise({ 43 | redirectTo: '/maps' 44 | }); 45 | }]); 46 | 47 | ///////////////// 48 | // EDITOR 49 | ///////////////// 50 | 51 | pinpointTool.controller('mapDetailCtrl', 52 | ['$scope', '$routeParams', '$http', '$location', 'mapHelper', 'markerStyles', 'mapDefaults', 'dataWrangler', 'configService', 53 | function ($scope, $routeParams, $http, $location, mapHelper, markerStyles, mapDefaults, dataWrangler, configService ) { 54 | 55 | $scope.mapId = $routeParams['mapId']; 56 | $scope.icons = markerStyles.icons; 57 | $scope.labels = markerStyles.labels; 58 | $scope.aspectRatios = ['wide','square','tall']; 59 | $scope.pickedLocation = {}; 60 | $scope.config = configService; 61 | var basemaps = []; 62 | if ('basemaps' in $scope.config) { 63 | $scope.basemapNames = $scope.config.basemaps.map(function(d,i) { return d.name }); 64 | } 65 | if (basemaps[0]) { 66 | $scope.basemap = basemaps[0].name; 67 | } 68 | 69 | if ($scope.mapId === 'new') { 70 | $scope.map = $.extend({}, mapDefaults.map); 71 | $scope.map.aspectRatio = $scope.map['aspect-ratio']; 72 | } else { 73 | $http.get('/api/maps/'+$scope.mapId) 74 | .success(function(data) { 75 | $scope.map = data; 76 | $.extend({}, mapDefaults.map, $scope.map); 77 | 78 | $scope = dataWrangler.setupExisting($scope); 79 | 80 | }) 81 | .error(function(){ 82 | $location.path('/maps'); 83 | }); 84 | } 85 | 86 | $scope.$watch(function() { 87 | 88 | // This is a horribly ugly hack, but I am at my wit's end 89 | var selectedBasemapName = $('.basemap-selector .btn.active').text(); 90 | 91 | if ($scope.map) { 92 | if ((selectedBasemapName !== '') && ($scope.map !== undefined)) { 93 | var selectedBasemap = $scope.config.basemaps.filter(function(basemap) { 94 | return basemap.name === selectedBasemapName; 95 | })[0]; 96 | $scope.map.basemap = selectedBasemap.url; 97 | $scope.map.basemapCredit = selectedBasemap.credit; 98 | } else if (!$scope.map.basemap && $scope.config.basemaps.length) { 99 | $scope.map.basemap = $scope.config.basemaps[0].url; 100 | } 101 | $scope.map = dataWrangler.onWatch($scope.map); 102 | $scope.cleanMap = JSON.stringify( dataWrangler.cleanMapObj($scope.map), null, 2 ); 103 | $scope.pinpoint = mapHelper.buildPreview($scope.map, changeMap, changeMap, changeMarker); 104 | } 105 | }); 106 | 107 | $scope.$watch('quickstartLatLonString', function(val){ 108 | if (val) { 109 | $scope.map.latLonString = val; 110 | var coords = { 111 | lat: val.split(',')[0], 112 | lon: val.split(',')[1] 113 | } 114 | $scope.addMarker( coords, $scope.quickstartName ); 115 | 116 | } 117 | }); 118 | 119 | $scope.showPublishModal = function(){ 120 | $scope.publishModal = true; 121 | } 122 | $scope.hidePublishModal = function(){ 123 | $scope.publishModal = false; 124 | } 125 | 126 | $scope.$watch('map.published', function(val){ 127 | if (val === true) { 128 | $scope.save(); 129 | } 130 | }); 131 | 132 | 133 | function changeMap(ev){ 134 | var newLatLon = ev.target.getCenter(); 135 | var newZoom = ev.target.getZoom(); 136 | $scope.map.latLonString = newLatLon.lat+','+newLatLon.lng; 137 | $scope.map.zoom = newZoom; 138 | $scope.$$childHead.mapform.$setDirty(); 139 | $scope.$apply(); 140 | } 141 | 142 | function changeMarker(ev){ 143 | var marker = ev.target; 144 | var newLatLon = marker._latlng; 145 | $.each($scope.map.markers, function(i,m){ 146 | if (marker.options.title === i) { 147 | $scope.map.markers[i].latLonString = newLatLon.lat+','+newLatLon.lng; 148 | } 149 | }); 150 | $scope.$$childHead.mapform.$setDirty(); 151 | $scope.$apply(); 152 | } 153 | 154 | 155 | $scope.$on('$destroy', function() { 156 | window.onbeforeunload = undefined; 157 | }); 158 | $scope.$on('$locationChangeStart', function(event, next, current) { 159 | if ($scope.$$childHead.mapform && !$scope.$$childHead.mapform.$pristine && !$scope.bypassSaveDialog) { 160 | if(!confirm("Leave page without saving?")) { 161 | event.preventDefault(); 162 | } 163 | } 164 | }); 165 | 166 | 167 | 168 | $scope.removeMarker = function(marker){ 169 | var index = $scope.map.markers.indexOf(marker); 170 | if (index > -1) { 171 | $scope.map.markers.splice(index, 1); 172 | } 173 | } 174 | $scope.addMarker = function(center, label){ 175 | if ($scope.map.markers.length > 4) { 176 | return; 177 | } 178 | 179 | var newMarker = $.extend({}, mapDefaults.marker); 180 | if ($scope.pinpoint) { 181 | center = center || $scope.pinpoint.map.getCenter(); 182 | newMarker.lat = center.lat; 183 | newMarker.lon = center.lng || center.lon; 184 | newMarker.latLonString = newMarker.lat+','+newMarker.lon; 185 | } 186 | newMarker.text = label || ''; 187 | newMarker.labelDirection = newMarker['label-direction']; 188 | $scope.map.markers.push( newMarker ); 189 | } 190 | 191 | $scope.save = function(valid){ 192 | if (valid === false) { 193 | return; 194 | } 195 | $scope.saving = true; 196 | var dirty = JSON.parse(JSON.stringify($scope.map)); 197 | var clean = dataWrangler.cleanMapObj(dirty); 198 | if ($scope.map.id && ($scope.map.id !== 'new')) { 199 | // update map 200 | $http 201 | .put('/api/maps/'+$scope.mapId, clean) 202 | .success(function(){ 203 | $scope.saving = false; 204 | if ($scope.$$childHead.mapform) { 205 | $scope.$$childHead.mapform.$setPristine(); 206 | } 207 | }); 208 | } else { 209 | // create a new map 210 | $http 211 | .post('/api/maps/', clean) 212 | .success(function(d){ 213 | $scope.map.id = d.id; 214 | $scope.saving = false; 215 | $location.path('/maps/'+d.id); 216 | $scope.$$childHead.mapform.$setPristine(); 217 | }); 218 | } 219 | if ($scope.map.published === true) { 220 | $scope.publish(); 221 | } 222 | } 223 | $scope.publish = function(valid){ 224 | if (valid === false) { 225 | return; 226 | } 227 | var dirty = JSON.parse(JSON.stringify($scope.map)); 228 | var clean = dataWrangler.cleanMapObj(dirty); 229 | if ($scope.mapId !== 'new') { 230 | clean.id = +$scope.mapId; 231 | } 232 | 233 | $http 234 | .post('/api/publish/', clean) 235 | .success(function(e,r){ 236 | $scope.$$childHead.mapform.$setPristine(); 237 | $scope.published = true; 238 | }) 239 | .error(function(){ 240 | alert('Not published due to error'); 241 | }); 242 | } 243 | $scope.delete = function(){ 244 | $scope.deleteModal = true; 245 | } 246 | $scope.cancelDelete = function(){ 247 | $scope.deleteModal = false; 248 | } 249 | $scope.definitelyDelete = function(){ 250 | if ($scope.map.id && ($scope.map.id !== 'new')) { 251 | // existing map 252 | $http 253 | .delete('/api/maps/'+$scope.map.id) 254 | .success(function(e,r){ 255 | alert('Map deleted'); 256 | $scope.bypassSaveDialog = true; 257 | $location.path('/maps/'); 258 | }) 259 | .error(function(){ 260 | alert('Not deleted due to error'); 261 | $scope.deleteModal = false; 262 | }); 263 | 264 | } else { 265 | $scope.bypassSaveDialog = true; 266 | $location.path('/maps/'); 267 | 268 | } 269 | } 270 | 271 | 272 | }]); 273 | 274 | 275 | pinpointTool.factory('mapHelper', [function() { 276 | var p; 277 | var build = function(opts, dragend, zoomend, markerdragend){ 278 | opts.dragend = dragend; 279 | opts.zoomend = zoomend; 280 | opts.markerdragend = markerdragend; 281 | 282 | $('.map-outer.inactive').html('
'); 283 | if (typeof p !== 'undefined') { 284 | try { 285 | p.remove(); 286 | } catch (err) { 287 | // 288 | } 289 | } 290 | opts.creation = true; 291 | opts.el = '.map-preview'; 292 | if ( $(opts.el).length === 1 ) { 293 | $(opts.el).attr('class',opts.el.replace('.','') + ' '+opts['aspect-ratio']); 294 | p = new Pinpoint(opts); 295 | } 296 | return p; 297 | }; 298 | var splitLatLonString = function(string){ 299 | if (!string) { 300 | return [0,0]; 301 | } 302 | var lat = +string.replace(/\s/g,'').split(',')[0]; 303 | var lon = +string.replace(/\s/g,'').split(',')[1]; 304 | return [lat, lon]; 305 | } 306 | 307 | return { 308 | buildPreview: build, 309 | splitLatLonString: splitLatLonString 310 | }; 311 | }]); 312 | 313 | pinpointTool.factory('markerStyles', function() { 314 | 315 | var icons = [ 316 | "square", 317 | "circle", 318 | "none" 319 | ]; 320 | var labels_obj = [ 321 | { 322 | "name": "plain", 323 | "directions": [ 324 | "north", 325 | "northeast", 326 | "east", 327 | "southeast", 328 | "south", 329 | "southwest", 330 | "west", 331 | "northwest" 332 | ] 333 | }, 334 | { 335 | "name": "callout", 336 | "directions": [ 337 | "north", 338 | "south" 339 | ] 340 | } 341 | ]; 342 | var getDirectionsforLabel = function( label ) { 343 | labels_obj.forEach(function(l){ 344 | if (l.name === label) { 345 | return l.directions; 346 | } 347 | }); 348 | } 349 | var labels = [], labels_directions = []; 350 | labels_obj.forEach(function(l){ 351 | labels.push(l.name); 352 | }); 353 | labels_obj.forEach(function(l){ 354 | labels_directions[l.name] = l.directions; 355 | }); 356 | 357 | return { 358 | icons: icons, 359 | labels: labels, 360 | directions: labels_directions 361 | }; 362 | }); 363 | 364 | pinpointTool.value('mapDefaults', { 365 | map: { 366 | hed: '', 367 | dek: '', 368 | lat: 51.5049378, 369 | lon: -0.0870377, 370 | latLonString: '51.5049378, -0.0870377', 371 | zoom: 4, 372 | minimap: false, 373 | "aspect-ratio": 'wide', 374 | "minimap-zoom-offset": -5, 375 | markers: [] 376 | }, 377 | marker: { 378 | lat: 0, 379 | lon: 0, 380 | text: "", 381 | icon: "square", 382 | "label-direction": "north" 383 | } 384 | }); 385 | 386 | pinpointTool.factory('dataWrangler', ['mapHelper', 'markerStyles', function(mapHelper, markerStyles){ 387 | var clean = function(input){ 388 | var output = JSON.parse(JSON.stringify(input)); 389 | var toDelete = [ 390 | 'labelDirections', 391 | 'latLonString', 392 | 'el', 393 | 'id', 394 | 'aspectRatio', 395 | 'minimapZoomOffset', 396 | 'labelDirection', 397 | 'creation', 398 | 'creation_date', 399 | 'modification_date' 400 | ]; 401 | $.each(toDelete, function(i, d){ 402 | delete output[d]; 403 | }); 404 | $.each(input.markers, function(j, marker){ 405 | $.each(toDelete, function(i, d){ 406 | delete output.markers[j][d]; 407 | }); 408 | }); 409 | if (output.geojson && output.geojson.features.length === 0) { 410 | delete output.geojson; 411 | } 412 | if (output.markers.length === 0) { 413 | delete output.markers; 414 | } 415 | return output; 416 | } 417 | var setupExisting = function(scope) { 418 | if (scope.map.lat && scope.map.lon) { 419 | scope.map.latLonString = scope.map.lat + ',' + scope.map.lon; 420 | } else { 421 | scope.map.latLonString = '51.5049378,-0.0870377'; 422 | } 423 | scope.map.minimapZoomOffset = scope.map['minimap-zoom-offset']; 424 | scope.map.aspectRatio = scope.map['aspect-ratio']; 425 | 426 | if (typeof scope.map.minimapZoomOffset !== 'number') { 427 | scope.map.minimapZoomOffset = -5; 428 | } 429 | 430 | scope.map.markers = scope.map.markers || []; 431 | $.each(scope.map.markers, function(i,m){ 432 | if (m.lat && m.lon) { 433 | m.latLonString = m.lat + ',' + m.lon; 434 | } else { 435 | m.latLonString = '51.5049378,-0.0870377'; 436 | } 437 | m.labelDirections = markerStyles.directions[m.label]; 438 | m['label-direction'] = m['label-direction'] || m.labelDirections[0]; 439 | scope.map.markers[i] = m; 440 | }); 441 | 442 | if (scope.map.basemap && scope.config.basemaps) { 443 | scope.basemap = scope.config.basemaps.filter(function(b) { 444 | return b.url === scope.map.basemap; 445 | })[0]; 446 | } 447 | 448 | return scope; 449 | 450 | } 451 | var watch = function(map){ 452 | map.zoom = parseInt( map.zoom ); 453 | map.lat = mapHelper.splitLatLonString(map.latLonString)[0]; 454 | map.lon = mapHelper.splitLatLonString(map.latLonString)[1]; 455 | map['minimap-zoom-offset'] = +map.minimapZoomOffset || map['minimap-zoom-offset']; 456 | map['aspect-ratio'] = map.aspectRatio || map['aspect-ratio']; 457 | $.each(map.markers, function(i,m){ 458 | m.labelDirections = markerStyles.directions[m.label]; 459 | m['label-direction'] = m.labelDirection || m['label-direction']; 460 | m.lat = mapHelper.splitLatLonString(m.latLonString)[0]; 461 | m.lon = mapHelper.splitLatLonString(m.latLonString)[1]; 462 | map.markers[i] = m; 463 | }); 464 | 465 | return map; 466 | } 467 | return { 468 | cleanMapObj: clean, 469 | setupExisting: setupExisting, 470 | onWatch: watch 471 | } 472 | }]); 473 | 474 | ///////////////// 475 | // HOMEPAGE 476 | ///////////////// 477 | 478 | pinpointTool.controller('mapListCtrl', 479 | ['$scope', '$http', '$location', '$filter', '$sce', 'configService', function ($scope, $http, $location, $filter, $sce, configService) { 480 | $scope.config = configService; 481 | $scope.listView = false; 482 | $scope.changeView = function(){ 483 | $scope.listView = !$scope.listView; 484 | } 485 | $scope.maps = []; 486 | $scope.allMaps = []; 487 | $http.get('/api/maps').success(function(data) { 488 | $scope.allMaps = $filter('orderBy')(data, 'creation_date', true);; 489 | $scope.loadMore(); 490 | }); 491 | 492 | var numberToLoadEachTime = 10; 493 | $scope.loadMore = function() { 494 | $scope.maps = $scope.allMaps.slice(0, $scope.maps.length + numberToLoadEachTime); 495 | $scope.hideLoadMore = ($scope.maps.length === $scope.allMaps.length); 496 | } 497 | 498 | $scope.previewLink = function(map){ 499 | if (map['aspect-ratio'] === 'wide') { 500 | var layout = 'offset'; 501 | } else { 502 | var layout = 'margin'; 503 | } 504 | var url = configService.previewLink + map.slug; 505 | return url; 506 | } 507 | $scope.liveLink = function(map){ 508 | var url = configService.liveLink+attr.slug; 509 | return url; 510 | } 511 | 512 | }]); 513 | 514 | pinpointTool.filter('html', function($sce) { 515 | return function(val) { 516 | return $sce.trustAsHtml(val); 517 | }; 518 | }); 519 | 520 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | padding-bottom: 20px; 4 | } 5 | 6 | 7 | 8 | /* menu */ 9 | 10 | .thumbnail { 11 | min-height: 240px !important; 12 | } 13 | .thumbnail:hover { 14 | text-decoration: none; 15 | } 16 | 17 | h4 small { 18 | white-space: nowrap; 19 | } 20 | 21 | .published-label { 22 | opacity: 0.75; 23 | } 24 | 25 | 26 | /* map editor */ 27 | 28 | .map-outer { 29 | // height: 400px; 30 | } 31 | 32 | #map { 33 | height: 100%; 34 | } 35 | 36 | .container { 37 | margin-top: 40px; 38 | } 39 | 40 | .marker-box { 41 | // border: 1px solid #ddd; 42 | // padding: 20px; 43 | } 44 | 45 | .map-preview { 46 | width: 300px; 47 | background: white; 48 | } 49 | 50 | .map-preview.wide { 51 | // width: 540px; 52 | } 53 | 54 | .delete-marker { 55 | float: right; 56 | padding: 0; 57 | color: white; 58 | } 59 | .delete-marker:hover { 60 | color: white; 61 | } 62 | .viewToggle { 63 | float: right; 64 | } 65 | .prevmap { 66 | height: 100px; 67 | pointer-events: none; 68 | } 69 | .new-map { 70 | text-align: center; 71 | padding: 60px 0; 72 | } 73 | 74 | 75 | .search-form { 76 | border-radius: 30px; 77 | } 78 | 79 | .save-button { 80 | margin-left: 20px; 81 | } 82 | 83 | .save-notice { 84 | font-size: 12px; 85 | color: #777; 86 | display: inline-block; 87 | margin-left: 10px; 88 | } 89 | 90 | .delete-true-button { 91 | margin-right: 50px !important; 92 | } 93 | 94 | input[type="range"] { 95 | width: 25%; 96 | display: inline-block; 97 | vertical-align: middle; 98 | margin-left: 10px; 99 | } 100 | 101 | .help-button { 102 | float: right; 103 | } 104 | 105 | .zoom-input { 106 | width: 40px; 107 | text-align: center; 108 | } 109 | 110 | div.leaflet-control-minimap.leaflet-container.leaflet-retina.leaflet-fade-anim.leaflet-control, 111 | a.leaflet-control-zoom-in { 112 | box-sizing: content-box; 113 | } 114 | 115 | label[disabled] { 116 | cursor: default; 117 | color: #aaa; 118 | } 119 | 120 | .json-input { 121 | font-family: 'Andale Mono', monospace; 122 | font-size: 11px; 123 | -webkit-user-select: all; 124 | -moz-user-select: all; 125 | -ms-user-select: all; 126 | user-select: all; 127 | } 128 | 129 | .geojson-input { 130 | font-family: 'Andale Mono', monospace; 131 | font-size: 10px; 132 | } 133 | 134 | .modal-dialog { 135 | z-index: 50000; 136 | } 137 | 138 | @media (min-width: 800px) { 139 | .map-preview { 140 | position: fixed; 141 | right: 60%; 142 | } 143 | } 144 | 145 | 146 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var app = express(); 3 | var morgan = require('morgan'); 4 | var bodyParser = require('body-parser'); 5 | var fs = require('fs'); 6 | var check = require('./app/setup-check.js'); 7 | 8 | var port = process.env.PORT || 3001; 9 | 10 | app.use(morgan('dev')); 11 | app.use(bodyParser.urlencoded({'extended':'true'})); 12 | app.use(bodyParser.json()); 13 | app.use(function(req, res, next) { 14 | res.header("Access-Control-Allow-Origin", "*"); 15 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); 16 | next(); 17 | }); 18 | 19 | check(function(){ 20 | require('./app/routes.js')(app); 21 | app.use(express.static(__dirname + '/public')); 22 | 23 | app.listen(port).on('error', function(err) { 24 | if (err.errno === 'EADDRINUSE') { 25 | throw('Error: Port '+port+' is already in use, which means that Pinpoint is probably already running on this computer.'); 26 | } 27 | }); 28 | console.log("I'm on port " + port); 29 | }); 30 | 31 | 32 | 33 | --------------------------------------------------------------------------------