Skip to content

Instantly share code, notes, and snippets.

@janosh
Last active July 4, 2021 15:29
Show Gist options
  • Save janosh/0bfc51769aa1969ef24f7f557b5a8b86 to your computer and use it in GitHub Desktop.
Save janosh/0bfc51769aa1969ef24f7f557b5a8b86 to your computer and use it in GitHub Desktop.
Google Maps previously used for showing Studenten bilden Schüler chapters across Germany at https://studenten-bilden-schueler.de/standorte
<script>
// This component uses the Google Maps Places API to turn user text input into a
// formatted address and lat/lng coordinates.
import { onMount } from 'svelte'
import { session } from '$app/stores'
export let selectHandler
export let placeholder = ``
export let required = false
export let name = ``
export let inputNode = undefined
let autocomplete
const autocompleteOptions = {
componentRestrictions: { country: `de` },
fields: [`formatted_address`, `geometry`, `name`],
types: [`geocode`, `establishment`],
}
function mountInput() {
autocomplete = new window.google.maps.places.Autocomplete(
inputNode,
autocompleteOptions
)
autocomplete.addListener(`place_changed`, () => {
inputNode.value = ``
inputNode.focus()
selectHandler(autocomplete.getPlace())
})
}
onMount(() => {
let script = document.getElementById(`gm-js-api`)
if (!script) {
script = document.createElement(`script`) // dynamically created scripts are async by default
script.src = `https://maps.googleapis.com/maps/api/js?key=${$session.GOOGLE_MAPS_API_KEY}&libraries=places`
script.id = `gm-js-api`
document.head.append(script)
}
if (!window.google?.maps) script.addEventListener(`load`, mountInput)
else mountInput()
return () => window.google.maps.event.clearInstanceListeners(autocomplete) // cleanup function, avoids mem leaks
})
</script>
<input bind:this={inputNode} {name} type="text" {placeholder} {required} />
<style>
input {
background: var(--accentBg);
width: 100%;
text-overflow: ellipsis;
height: 2em;
margin: 1ex 0;
}
/* make autocomplete suggestions color-mode responsive */
/* https://developers.google.com/maps/documentation/javascript/places-autocomplete#style-autocomplete */
:global(.pac-container) {
background: var(--accentBg);
border-radius: 3pt;
}
:global(.pac-item):where(:hover, .pac-item-selected) {
background: var(--bodyBg);
}
:global(.pac-item span) {
color: var(--textColor);
}
/* hide google logo from autocomplete suggestions */
:global(.pac-logo::after) {
display: none;
}
</style>
<script>
import { goto, prefetch } from '$app/navigation'
import Map from '../components/Map.svelte'
export let chapters
// chapters can be inside the addMarkers closure because it's static
// if chapters might change and we wanted the markers to rerender,
// it would need to be part of the map's props
const addMarkers = (map) => {
chapters.forEach(({ title, slug, coords, acceptsSignups }) => {
const icon = {
path: `m0 0h24v14h-24z m12 12-3-10h4.56z`, // needed to remove the default Google Maps marker as well as to make the correct map area behind the label clickable
fillColor: `transparent`,
strokeWeight: 0, // remove this to see the SVG path, helps position it correctly behind the labels
anchor: new window.google.maps.Point(11, 30),
labelOrigin: new window.google.maps.Point(10, 10),
}
const marker = new window.google.maps.Marker({
map,
icon,
position: coords,
label: {
text: title.slice(0, 2),
color: `white`,
className: `chap ` + (acceptsSignups ? `old` : `new`),
},
title,
})
marker.addListener(`mouseover`, () => prefetch(slug))
marker.addListener(`click`, () => goto(slug))
})
}
</script>
<div>
<Map onLoad={addMarkers} />
<legend>
<span style="background: var(--darkGreen)" /> aktiver Standort&ensp;
<span style="background: var(--blue)" /> in Gründung</legend>
</div>
<style>
div {
position: relative;
}
legend {
position: absolute;
right: 1ex;
bottom: 1ex;
color: white;
background: rgba(0, 0, 0, 0.7);
padding: 2pt 4pt;
border-radius: 3pt;
}
span {
width: 2ex;
height: 2ex;
display: inline-block;
border-radius: 5pt;
box-shadow: 0 0 2pt;
vertical-align: text-bottom;
}
:global(div.chap) {
opacity: 0.9;
padding: 0 2pt;
border-radius: 1pt;
position: relative;
}
:global(div.chap::after) {
content: '';
position: absolute;
left: 50%;
top: 100%;
transform: translate(-50%);
border: solid;
border-width: 10pt 4pt;
box-sizing: border-box;
}
:global(div.chap.old::after) {
border-color: var(--darkGreen) transparent transparent transparent;
}
:global(div.chap.new::after) {
border-color: var(--blue) transparent transparent transparent;
}
:global(div.chap.old) {
background: var(--darkGreen);
}
:global(div.chap.new) {
background: var(--blue);
border-color: var(--blue) transparent transparent transparent;
}
</style>
<script>
import { session } from '$app/stores'
import { onMount } from 'svelte'
export let map = undefined
export let onLoad = () => {}
export let mapDiv = undefined
export let mapProps = {}
export let mapDivCss = `height: 700px; max-height: 75vh; min-height: 530px;`
// default map props
mapProps = {
center: { lat: 51.5, lng: 10 },
zoom: 6,
disableDefaultUI: true,
...mapProps,
}
const mountMap = () => (map = new window.google.maps.Map(mapDiv, mapProps))
// This includes the places library to avoid needing to reimport Google Maps in
// AutoCompletePlace.svelte which would warn "You have included the Google Maps
// JavaScript API multiple times on this page. This may cause unexpected errors."
$: if (map && typeof onLoad === `function`) onLoad(map)
onMount(() => {
let script = document.getElementById(`gm-js-api`)
if (!script) {
script = document.createElement(`script`) // dynamically created scripts are async by default
script.src = `https://maps.googleapis.com/maps/api/js?key=${$session.GOOGLE_MAPS_API_KEY}&libraries=places`
script.id = `gm-js-api`
document.head.append(script)
}
if (!window.google?.maps) script.addEventListener(`load`, mountMap)
else mountMap()
})
</script>
<div bind:this={mapDiv} style={mapDivCss} />
<style>
div {
height: 700px;
max-height: 75vh;
min-height: 530px;
}
/* hide footer https://stackoverflow.com/a/22581969 */
:global(.gm-style-cc) {
display: none;
}
</style>
<script>
// This component uses the Google Maps Places API to turn user text input into a
// formatted address and lat/lng coordinates.
import Map from './Map.svelte'
import AutoCompletePlace from './AutoCompletePlace.svelte'
import Delete from '@svicons/material-sharp/delete.svelte'
export let placeholder = ``
export let required = false
export let name = ``
export let input = undefined // hidden input field used to store value until it is read when submitting the form
let places = []
let markers = []
let map, inputNode
function selectHandler(place) {
if (!place.geometry?.location) {
// User entered the name of a Place that was not suggested and
// pressed the Enter key, or the place details request failed.
window.alert(`Für '${place.name}' konnte keine Adresse gefunden werden!`)
return
}
const { lat, lng } = place.geometry?.location.toJSON()
places = [...places, { address: place.formatted_address, coords: `${lat},${lng}` }]
input.value = JSON.stringify(places)
const marker = new window.google.maps.Marker({
map,
position: { lat, lng },
label: { text: place.name.slice(0, 2), color: `white` },
title: place.name,
})
markers.push(marker)
const bounds = new window.google.maps.LatLngBounds()
for (const marker of markers) {
bounds.extend(marker.getPosition())
}
map.fitBounds(bounds)
}
const deletePlace = (idx) => () => {
// remove place from list
places.splice(idx, 1)
places = places
// remove marker from map
markers[idx].setMap(null)
markers.splice(idx, 1)
// reset input value without removed place
input.value = JSON.stringify(places)
}
</script>
<!-- for holding the component's value in a way accessible to the DOM -->
<input
bind:this={input}
{required}
{name}
id={name}
class="hidden"
tabindex="-1"
on:focus={() => inputNode.focus()} />
<!-- tabindex="-1" means skip element during tabbing, else we couldn't shift-tab out of filterInput as filterInput.focus() would jump right back -->
<AutoCompletePlace bind:inputNode {placeholder} {selectHandler} />
<ol>
{#each places as place, idx}
<li>
<span>{idx + 1}</span><input
data-place={idx + 1}
type="text"
value={place.address}
disabled />
<button on:click={deletePlace(idx)} type="button">
<Delete style="width: 3ex; vertical-align: middle;" /></button>
</li>
{/each}
</ol>
<Map bind:map />
<style>
ol {
padding: 0;
}
ol li {
margin: 1ex 0;
display: flex;
align-items: center;
}
ol li span {
font-size: 2.5ex;
padding: 2pt 6pt 2pt 2pt;
}
ol li span + input + button {
color: var(--lightGray);
transition: 0.3s;
}
ol li span + input + button:hover {
color: var(--gray);
}
input {
background: var(--accentBg);
width: 100%;
text-overflow: ellipsis;
height: 2em;
}
input.hidden {
border: none;
outline: none;
background: none;
padding: 0;
width: 1px;
/* needed to hide red shadow around required inputs in some browsers */
box-shadow: none;
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment