diff --git a/lectures/build.gradle b/lectures/build.gradle
index 13f17387144c256fca1498f744148dc6f2c04e96..37dec022b85f27e954575cb1f89a6c597f18a1c1 100644
--- a/lectures/build.gradle
+++ b/lectures/build.gradle
@@ -61,7 +61,7 @@ asciidoctorRevealJs {
     outputDir file('build/docs/revealjs')
 	resources {
 		from('revealjs') {
-	    	include '**/*.png'
+	    	include 'images/*'
 	    	include '**/*.css'
 	 	}
 		into '.'
diff --git a/lectures/revealjs/agiletools.adoc b/lectures/revealjs/agiletools.adoc
index aff88dae690f8df4003c449f2335ffab83942de9..4ef39ecca5bde7ff954d806cb819f31e2c6903d0 100644
--- a/lectures/revealjs/agiletools.adoc
+++ b/lectures/revealjs/agiletools.adoc
@@ -63,7 +63,7 @@ Hva har dette å si for verktøystøtte?
 
 == `gitlab.stud.idi...`
 
-[.smaller]
+[.smaller-80]
 * profesjonelt støtteverktøy for smidig utvikling
 * IDI har egen installasjon (tas kanskje over av NTNU IT)
 ** innlogging med NTNU-navn og -passord
@@ -93,7 +93,7 @@ Oppgavesporing er viktig for transparens
 
 == Oppgavesporing forts.
 
-[.smaller]
+[.smaller-80]
 * oppgaver opprettes ifm. planlegging av iterasjon, f.eks. fra _brukerhistorier_, _funksjonsønsker_ eller _feilrapporter_
 * oppgaver knyttes til
 ** _milepæl_ for iterasjon
diff --git a/lectures/revealjs/ci.adoc b/lectures/revealjs/ci.adoc
index e1389f36589737d8927bcde10dbd786f215531b4..1a87d4ecf081dff26ee1ce9abbbff9381a9312fd 100644
--- a/lectures/revealjs/ci.adoc
+++ b/lectures/revealjs/ci.adoc
@@ -1,3 +1,153 @@
-= CI
+= Kontinuerlig integrasjon
 :customcss: slides.css
 :icons: font
+
+== Kontinuerlig integrasjon (CI)
+
+Automatisering av alt som fremmer kvalitet, men som tar tid, f.eks.
+
+* kompilering og bygging av jar-filer (generelt avledete artifakter)
+* kjøring av enhets- og integrasjonstester
+* analyser av ulike typer kvalitet (formatering, kodingsstandarder, vanlige feil, ...)
+* bygging av kjørbart system og kjøring av systemtester
+
+== Smidig utfordring
+
+[.smaller-60]
+* Hvordan iterere raskt?
+** skrive korrekt kode raskt
+** være trygg på kvaliteten
+** levere ofte, for å få tilbakemelding fra brukere
+
+[.smaller-60]
+* Mange nivåer av testing
+** egen kode - enhetstesting
+** koden innen teamet - integrasjonstesting
+** hele systemet - systemtesting (og evt. deployment)
+
+== Smidig løsning
+
+[.smaller-80]
+* Kontinuerlig - bygg, sett sammen og test
+* Innimellom - lever (release) og sett i drift/prod. (deploy)
+* Alt for mye arbeid uten støtte
+** _byggeverktøy_ automatiserer prosessen
+** _byggetjenere_ sikrer reproduserbar prosess
+
+== Byggeverktøy
+
+[.smaller-80]
+* strukturert tilnærming basert på meta-informasjon
+** prosjekt-type (språk, plattform, ...)
+** struktur og avhengigeter
+** rammeverk og bibliotek automatisk lastet fra nettet
+* sekvens av byggetrinn med innbyrdes avhengigheter
+* mange konfigurasjonsmuligheter med gode standardinnstillinger (convention over configuration)
+* prosjektmaler gjør initiell rigging enklere
+* i stor grad styrt av tillegg (plugins) til et generell system
+
+== Byggeverktøy forts.
+
+[.smaller-60]
+* to hovedalternativer: *gradle* og *maven*
+** litt ulik terminologi og logikk, men forskjellene er tildels overfladiske
+** begge kan mye det samme, så valget er i stor grad pragmatisk (!)
+** masse materiale å lære fra, uansett valg
+* *maven*
+** mer modent og strukturert, men også mer krevende og tungvint 
+* *gradle*
+** konfigurerbare byggetrinn sekvensieres basert på avhengigheter 
+** konfig.filene (*settings.gradle* og *build.gradle*) er forståelige
+
+== Enkelt prosjektoppsett
+
+[.smaller-60]
+Enkle prosjekter består av én modul og mappe
+
+[.smaller-60]
+* *settings.gradle* og *build.gradle* konfigurerer bygg
+* *gradlew*/*gradlew.bat* og *gradle*-mappe må inkluderes
+
+[.smaller-60]
+Eksempel:
+
+[.smaller-60]
+* desktop-app med javafx og fxml
+* ikke noe behov for gjenbruk av kjernelogikk i andre prosjekter
+
+== simpleexample
+
+[.smaller]
+[.left-60]
+* eksempel på én-modul-prosjekt
+** javafx+fxml-app
+** standard mapper for java
+** gradle-filer
+
+[.right]
+image::../images/simpleexample-structure.png[width=300]
+
+== simpleexample forts.
+
+[.smaller]
+* standard innhold i *build.gradle*
+** plugins - tillegg som passer til typen prosjekt, f.eks. 
+** repositories - hvor finnes tillegg og bibliotek (jar-filer)
+** dependencies - hvilke bibliotek brukes og til hva/når
+* tillegg-spesifikk konfigurasjon, f.eks.
+** oppstartsklasse for java
+** javafx-versjon og -moduler som brukes
+
+== simpleexample forts.
+
+* build.gradle *plugins*
+** application - java-app med main-metode, som kan pakkes som kjørbar jar
+** org.openjfx.javafxplugin - javafx-app
+
+[source]
+----
+plugins {
+	id 'application'
+	id 'org.openjfx.javafxplugin' version '0.0.8'
+}
+----
+
+== Modulært prosjektoppsett
+
+[.smaller-60]
+_Flermodul_-prosjekter har delmoduler i undermapper
+
+[.smaller-60]
+* *settings.gradle* i hovedmappa navngir hovedprosjektet og delmodulene
+* *gradlew*/*gradlew.bat* og *gradle*-mappe må inkluderes i hovedmappa
+* delmodulene (i undermappene) trenger (bare) egen *build.gradle*-fil
+
+[.smaller-60]
+Eksempel:
+
+[.smaller-60]
+* klient-tjener-app
+* (noe) felles kjernelogikk i klient og tjener
+* kan ha flere klienter, f.eks. desktop og mobil (og web)
+
+== Modularitet
+
+[.smaller-60]
+* et modulært system er mer oversiktlig
+** moduler er relativt selvstendige enheter
+** eksplisitte avhengigheter mellom moduler
+** gjenbrukes på tvers av prosjekter
+* tilsvarer i java et sett pakker
+** skiller mellom API og interne pakker 
+** distribueres som jar-fil (zip-fil med meta-data)
+
+== multiexample
+
+[.smaller]
+[.left-60]
+* eksempel på fler-modul-prosjekt
+** core - kjernelogikk
+** fx - javafx+fxml-app
+** jaxrs - REST API
+** jersey - web-server-app
+** webreact - web-klient
diff --git a/lectures/revealjs/images/git-branching.png b/lectures/revealjs/images/git-branching.png
new file mode 100644
index 0000000000000000000000000000000000000000..4f3c85fabeaee04837f6f136209103aa70e149ef
Binary files /dev/null and b/lectures/revealjs/images/git-branching.png differ
diff --git a/lectures/revealjs/images/git-local-remote-repo.png b/lectures/revealjs/images/git-local-remote-repo.png
new file mode 100644
index 0000000000000000000000000000000000000000..adcc8387c22d7cc0439dc1d08603a4dcdb8bfa9b
Binary files /dev/null and b/lectures/revealjs/images/git-local-remote-repo.png differ
diff --git a/lectures/revealjs/images/git-repo-commands.png b/lectures/revealjs/images/git-repo-commands.png
new file mode 100644
index 0000000000000000000000000000000000000000..3d8c1f6c6e59d3548f76e41002a9f3287cb19e9a
Binary files /dev/null and b/lectures/revealjs/images/git-repo-commands.png differ
diff --git a/lectures/revealjs/images/git-repo-copies.png b/lectures/revealjs/images/git-repo-copies.png
new file mode 100644
index 0000000000000000000000000000000000000000..112478fff1be958aebc031536c8ca6542234d0d1
Binary files /dev/null and b/lectures/revealjs/images/git-repo-copies.png differ
diff --git a/lectures/revealjs/images/git-repositories-view.png b/lectures/revealjs/images/git-repositories-view.png
new file mode 100644
index 0000000000000000000000000000000000000000..d05160d7294f7c47cefaa6196a104ed9fa5fd8ca
Binary files /dev/null and b/lectures/revealjs/images/git-repositories-view.png differ
diff --git a/lectures/revealjs/images/git-staging-view.png b/lectures/revealjs/images/git-staging-view.png
new file mode 100644
index 0000000000000000000000000000000000000000..d6c307efd5c9f92f9e2652d66c1b0fb8668704d6
Binary files /dev/null and b/lectures/revealjs/images/git-staging-view.png differ
diff --git a/lectures/revealjs/images/simpleexample-structure.png b/lectures/revealjs/images/simpleexample-structure.png
new file mode 100644
index 0000000000000000000000000000000000000000..fdf2532cb6abcd865a7dbfc14d2adbeca52e6e4b
Binary files /dev/null and b/lectures/revealjs/images/simpleexample-structure.png differ
diff --git a/lectures/revealjs/scm.adoc b/lectures/revealjs/scm.adoc
index 33bde303b58bd583db9dd3572139a90e73d73322..d3bea3e52f1e599776d5267a7baa6deebd85f539 100644
--- a/lectures/revealjs/scm.adoc
+++ b/lectures/revealjs/scm.adoc
@@ -1,3 +1,124 @@
-= SCM
+= SCM - Source Code Management
 :customcss: slides.css
 :icons: font
+
+== Kildekodehåndtering
+
+* lagring av kode
+* sporing av endringer
+* versionering
+* distribusjon
+
+[.grid-left-right-50-50]
+== `git`
+
+[.smaller-60]
+[.area-left]
+* sporing og deling av kodeendringer i såkalte _repo_ (repository)
+* både sentrale og lokale repo har all endringshistorikk
+* initiell kopi hentes ved å _kloning_ (clone)
+* endringer (i filer) registreres i _commits_ 
+* commits deles med andre ved å _dytte_ (push) til en server
+* andre kan da _dra_ (pull) endringene inn i lokalt repo
+
+[.area-right]
+image::../images/git-local-remote-repo.png[]
+
+[.grid-left-right-50-50]
+== 4 repo-"kopier"
+
+[.smaller-60]
+[.area-left]
+* originalen (_origin_), hentet fra server (`pull`)
+* arbeidsområdet (_working directory_), lokale filer som kan være endret
+* indeksen (_staging area_ eller _index_), endringer du har lagt til med (`add`)
+* lokalt repo, endringene du har commitet (`commit`)
+
+[.area-right]
+image::../images/git-repo-copies.png[]
+
+== repo-kommandoer
+
+image::../images/git-repo-copies.png[width="400px"]
+image::../images/git-repo-commands.png[width="500px"]
+
+== Typisk sekvens
+
+[.smaller-80]
+* `git pull` (eller `clone` første gang) - henter ned endringer fra serveren
+* gjør egne endringer
+* `git status` - viser hva som er endret
+* `git add <fil eller mappe>` - legger endringer til fremtidig commit
+* `git commit -m <melding, m/oppgavenummer>` - registrerer all endringene (lagt til med `add`)
+* `git pull` - henter andres endringer, i tilfelle konflikt
+* `git push` - deler endringer med andre via serveren
+
+== Forgreining (branching)
+
+[.smaller-60]
+[.left-70]
+* greiner (branches)
+** sporer egne utviklingstråder
+** gjør jobbing i parallell ryddigere 
+* sammenslåing (merging)
+** en (hoved)grein slås gjerne sammen med en annen, når den andre er ferdig (nok)
+** etter sammenslåing, så deles gjerne resultatet med andre
+** andre henter så ned for å være oppdatert
+
+[.right]
+image::../images/git-branching.png[width="200px"]
+
+== Sekvens m/forgreining
+
+[.smaller-80]
+* `git pull` - henter ned endringer fra serveren
+* `git checkout -b <navn på (ny) grein>` - lager ny grein, med utgangspunkt i den du jobber med
+* gjør egne endringer
+* `git add ...` og  `git commit ...` - registrerer all endringene
+* `git checkout master` - bytter til hovedgrein
+* `git merge <navn på grein>` - slår annen grein (den nye) sammen med denne (hovedgreina)
+* ...
+
+== Eclipse-støtte for git
+
+[.smaller-60]
+* Egne Eclipse-tillegg - JGit (git-impl) og EGit (UI)
+* Git Repositories-panel - oversikt over repo
+** klone repo fra server eller registrere lokalt repo
+** utføre `pull` og `push`
+** utføre `checkout` inkl. lage ny grein
+** ...
+
+image::../images/git-repositories-view.png[height="200px"]
+
+== Eclipse-støtte forts.
+
+[.smaller-60]
+* Git Staging - statusoversikt
+** filer i arbeidsområdet som er endret
+** filene i indeksen
+** legge til (`add`) eller fjerne (`remove`) fra indeksen
+
+image::../images/git-staging-view.png[width="800px"]
+
+== Endringsforespørsler
+
+[.smaller-60]
+* en `pull`/`merge`-forespørsel (request) brukes for mer formell godkjenning av endringer
+** Pull Request er github-termen, mens Merge Request brukes i gitlab
+* brukes ofte for å inkludere endringer utenfra, f.eks.
+** utviklere utenfor kjerne-teamet
+** brukere av åpen kildekode som har fikset feil
+* forenkler administrasjon av åpne prosjekter
+
+== PR/MR-prosedyre
+
+[.smaller-60]
+* (utenforstående oppretter kopi på egen server)
+* lager en grein for endringene og utfører dem lokalt
+* i stedet for å slå sammen med egen hovedgrein
+** `push` grein til egen server
+** lag en  `pull`/`merge`-forespørsel (PR/MR), som (potensielt) kan inkluderes i hovedgreina
+** UI for dette finnes på github/i gitlab
+** forespørselen får en egen dialog/diskusjonstråd
+** en utvikler med rettigheter kan så godkjenne evt. avslå forespørsel
diff --git a/lectures/revealjs/slides.css b/lectures/revealjs/slides.css
index 6733f42d8b9c7149f3dd54d86103d08fd301fa25..2b45fec32a3b24c24757f0e26a441c2daa0d126b 100644
--- a/lectures/revealjs/slides.css
+++ b/lectures/revealjs/slides.css
@@ -10,6 +10,166 @@
 	text-align: center;
 }
 
-.smaller {
-	font-size: 80%
+.smaller-80 {
+	font-size: 80%;
+}
+
+.smaller-60 {
+	font-size: 60%;
+}
+
+.left {
+	float: left;
+}
+
+.left-90 {
+	float: left;
+  	width: 90%;
+}
+
+.left-80 {
+	float: left;
+  	width: 80%;
+}
+
+.left-70 {
+	float: left;
+  	width: 70%;
+}
+
+.left-60 {
+	float: left;
+  	width: 60%;
+}
+
+.left-50 {
+	float: left;
+  	width: 50%;
+}
+
+.left-40 {
+	float: left;
+  	width: 40%;
+}
+
+.left-30 {
+	float: left;
+  	width: 30%;
+}
+
+.right {
+	float: right;
+}
+
+.right-80 {
+	float: right;
+  	width: 80%;
+}
+
+.right-70 {
+	float: right;
+  	width: 70%;
+}
+
+.right-60 {
+	float: right;
+  	width: 60%;
+}
+
+.right-50 {
+	float: right;
+  	width: 50%;
+}
+
+.right-40 {
+	float: right;
+  	width: 40%;
+}
+
+.right-30 {
+	float: right;
+  	width: 30%;
+}
+
+section h2 {
+	grid-area: slide-title;
+}
+
+.reveal h2 {
+	text-transform: none;
+}
+
+.width-10 {
+	width: 10%;
+}
+.width-20 {
+	width: 20%;
+}
+.width-30 {
+	width: 30%;
+}
+.width-40 {
+	width: 40%;
+}
+.width-50 {
+	width: 50%;
+}
+.width-60 {
+	width: 60%;
+}
+.width-70 {
+	width: 70%;
+}
+.width-80 {
+	width: 80%;
+}
+.width-90 {
+	width: 90%;
+}
+
+.grid-left-right {
+	display: grid !important;
+	grid-template-areas: 'slide-title slide-title' 'left right';
+}
+.grid-left-right-50-50 {
+	display: grid !important;
+	grid-template-areas: 'slide-title slide-title' 'left right';
+	grid-template-columns: 50% 50%
+}
+
+.area-left {
+	grid-area: left;
+}
+.area-right {
+	grid-area: right;
+}
+
+.grid-compass {
+	display: grid !important;
+	grid-template-areas: 'slide-title slide-title slide-title' 'north-west north north-east' 'west middle east' 'south-west south south-east'; 
+}
+
+.area-north-west {
+	grid-area: north-west;
+}
+.area-north {
+	grid-area: north;
+}
+.area-north-east {
+	grid-area: north-east;
+}
+.area-west {
+	grid-area: west;
+}
+.area-east {
+	grid-area: east;
+}
+.area-south-west {
+	grid-area: south-west;
+}
+.area-south {
+	grid-area: south;
+}
+.area-south-east {
+	grid-area: south-east;
 }
diff --git a/simpleexample/.classpath b/simpleexample/.classpath
new file mode 100644
index 0000000000000000000000000000000000000000..10d994043387f57590a3a567df89e601500658ba
--- /dev/null
+++ b/simpleexample/.classpath
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<classpath>
+	<classpathentry kind="src" output="bin/main" path="src/main/java">
+		<attributes>
+			<attribute name="gradle_scope" value="main"/>
+			<attribute name="gradle_used_by_scope" value="main,test"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="src" output="bin/main" path="src/main/resources">
+		<attributes>
+			<attribute name="gradle_scope" value="main"/>
+			<attribute name="gradle_used_by_scope" value="main,test"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="src" output="bin/test" path="src/test/java">
+		<attributes>
+			<attribute name="gradle_scope" value="test"/>
+			<attribute name="gradle_used_by_scope" value="test"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="src" output="bin/test" path="src/test/resources">
+		<attributes>
+			<attribute name="gradle_scope" value="test"/>
+			<attribute name="gradle_used_by_scope" value="test"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-12/"/>
+	<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
+	<classpathentry kind="output" path="bin/default"/>
+</classpath>
diff --git a/simpleexample/.gitignore b/simpleexample/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..837b6f64147e83239994f1736b624de993f36476
--- /dev/null
+++ b/simpleexample/.gitignore
@@ -0,0 +1,6 @@
+# Ignore Gradle project-specific cache directory
+.gradle
+
+# Ignore Gradle build output directory
+build/
+bin/
diff --git a/simpleexample/.gitignore~ b/simpleexample/.gitignore~
new file mode 100644
index 0000000000000000000000000000000000000000..1b6985c0094c8e3db5f1c6e2c4d66b82f325284f
--- /dev/null
+++ b/simpleexample/.gitignore~
@@ -0,0 +1,5 @@
+# Ignore Gradle project-specific cache directory
+.gradle
+
+# Ignore Gradle build output directory
+build
diff --git a/simpleexample/.project b/simpleexample/.project
new file mode 100644
index 0000000000000000000000000000000000000000..95eb911346f50860ea21d5eee970164e9e41e8c0
--- /dev/null
+++ b/simpleexample/.project
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+	<name>simpleexample</name>
+	<comment>Project simpleexample created by Buildship.</comment>
+	<projects>
+	</projects>
+	<buildSpec>
+		<buildCommand>
+			<name>org.eclipse.jdt.core.javabuilder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+		<buildCommand>
+			<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+	</buildSpec>
+	<natures>
+		<nature>org.eclipse.jdt.core.javanature</nature>
+		<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
+	</natures>
+</projectDescription>
diff --git a/simpleexample/.settings/org.eclipse.buildship.core.prefs b/simpleexample/.settings/org.eclipse.buildship.core.prefs
new file mode 100644
index 0000000000000000000000000000000000000000..e8895216fd3c0c3af4c4522334775f41b7deb42e
--- /dev/null
+++ b/simpleexample/.settings/org.eclipse.buildship.core.prefs
@@ -0,0 +1,2 @@
+connection.project.dir=
+eclipse.preferences.version=1
diff --git a/simpleexample/build.gradle b/simpleexample/build.gradle
new file mode 100644
index 0000000000000000000000000000000000000000..01d55f6e311f372a9e58d5b5211567833c38c745
--- /dev/null
+++ b/simpleexample/build.gradle
@@ -0,0 +1,33 @@
+plugins {
+	id 'application'
+    id 'org.openjfx.javafxplugin' version '0.0.8'
+}
+
+repositories {
+    jcenter()
+    maven {
+        url "https://plugins.gradle.org/m2/"
+    }
+    mavenLocal()
+}
+
+mainClassName = 'simpleex.ui.FxApp' // application plugin
+
+// javafx specific way of specifying dependencies
+javafx {
+	version = '12'
+    modules = [ 'javafx.controls', 'javafx.fxml' ]
+    // run with --debug-jvm flag and
+    // launch debugger using Remove Java Application debug launch configuration
+}
+
+
+dependencies {
+    compile "com.fasterxml.jackson.core:jackson-databind:2.9.8"
+    compile 'fischer.clemens:FxMapControl:1.0'
+    
+    testImplementation 'junit:junit:4.12'
+    testImplementation "org.testfx:testfx-core:4.0.16-alpha"
+    testImplementation "org.testfx:testfx-junit:4.0.16-alpha"
+	testImplementation "org.mockito:mockito-core:2.28.2"
+}
diff --git a/simpleexample/gradle/wrapper/gradle-wrapper.jar b/simpleexample/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000000000000000000000000000000000..5c2d1cf016b3885f6930543d57b744ea8c220a1a
Binary files /dev/null and b/simpleexample/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/simpleexample/gradle/wrapper/gradle-wrapper.properties b/simpleexample/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000000000000000000000000000000000..5f1b1201a7353e7a48f40c490e7c799a6318d02e
--- /dev/null
+++ b/simpleexample/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-5.4-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/simpleexample/gradlew b/simpleexample/gradlew
new file mode 100755
index 0000000000000000000000000000000000000000..b0d6d0ab5deb588123dd658f0b079934ee05a72e
--- /dev/null
+++ b/simpleexample/gradlew
@@ -0,0 +1,188 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+    echo "$*"
+}
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+  NONSTOP* )
+    nonstop=true
+    ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+        JAVACMD="$JAVA_HOME/bin/java"
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD="java"
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+    JAVACMD=`cygpath --unix "$JAVACMD"`
+
+    # We build the pattern for arguments to be converted via cygpath
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    # Add a user-defined pattern to the cygpath arguments
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
+
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=$((i+1))
+    done
+    case $i in
+        (0) set -- ;;
+        (1) set -- "$args0" ;;
+        (2) set -- "$args0" "$args1" ;;
+        (3) set -- "$args0" "$args1" "$args2" ;;
+        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+# Escape application args
+save () {
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+    echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+  cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/simpleexample/gradlew.bat b/simpleexample/gradlew.bat
new file mode 100644
index 0000000000000000000000000000000000000000..15e1ee37a70d7dfdfd8cf727022e117b6f6153a7
--- /dev/null
+++ b/simpleexample/gradlew.bat
@@ -0,0 +1,100 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      http://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/simpleexample/settings.gradle b/simpleexample/settings.gradle
new file mode 100644
index 0000000000000000000000000000000000000000..c6dd73e548547a2e83b46363d8470c7224cd669a
--- /dev/null
+++ b/simpleexample/settings.gradle
@@ -0,0 +1,10 @@
+/*
+ * This file was generated by the Gradle 'init' task.
+ *
+ * The settings file is used to specify which projects to include in your build.
+ *
+ * Detailed information about configuring a multi-project build in Gradle can be found
+ * in the user manual at https://docs.gradle.org/5.4/userguide/multi_project_builds.html
+ */
+
+rootProject.name = 'simpleexample'
diff --git a/simpleexample/src/main/java/fxutil/doc/AbstractDocumentStorageImpl.java b/simpleexample/src/main/java/fxutil/doc/AbstractDocumentStorageImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..13bc9e940ad4744dd9b5aa3b07e6e3ddf8b956f2
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/AbstractDocumentStorageImpl.java
@@ -0,0 +1,145 @@
+package fxutil.doc;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+
+/**
+ * Incomplete implementation of **IDocumentStorage**, to simplify implementing ones for specific document and location types.
+ * The main missing methods are for getting and setting the current document, creating an empty one and
+ * creating an **InputStream** from a location.
+ * @author hal
+ *
+ * @param <D> the document type
+ * @param <L> the location type
+ */
+public abstract class AbstractDocumentStorageImpl<D, L> implements IDocumentStorage<L>, IDocumentPersistence<D, L> {
+
+	private L documentLocation;
+
+	@Override
+	public L getDocumentLocation() {
+		return documentLocation;
+	}
+
+	@Override
+	public void setDocumentLocation(final L documentLocation) {
+		final L oldDocumentLocation = this.documentLocation;
+		this.documentLocation = documentLocation;
+		fireDocumentLocationChanged(oldDocumentLocation);
+	}
+
+	protected void setDocumentAndLocation(final D document, final L documentLocation) {
+		setDocument(document);
+		setDocumentLocation(documentLocation);
+	}
+
+	/**
+	 * Returns the current document.
+	 * @return the current document
+	 */
+	protected abstract D getDocument();
+
+	/**
+	 * Sets the current document
+	 * @param document the new document
+	 */
+	protected abstract void setDocument(D document);
+
+	//
+
+	private final Collection<IDocumentStorageListener<L>> documentListeners = new ArrayList<IDocumentStorageListener<L>>();
+
+	@Override
+	public void addDocumentStorageListener(final IDocumentStorageListener<L> documentStorageListener) {
+		documentListeners.add(documentStorageListener);
+	}
+
+	@Override
+	public void removeDocumentStorageListener(final IDocumentStorageListener<L> documentStorageListener) {
+		documentListeners.remove(documentStorageListener);
+	}
+
+	protected void fireDocumentLocationChanged(final L oldDocumentLocation) {
+		for (final IDocumentStorageListener<L> documentStorageListener : documentListeners) {
+			documentStorageListener.documentLocationChanged(documentLocation, oldDocumentLocation);
+		}
+	}
+
+	protected void fireDocumentChanged(final D oldDocument) {
+		for (final IDocumentStorageListener<L> documentListener : documentListeners) {
+			if (documentListener instanceof IDocumentListener) {
+				((IDocumentListener<D, L>) documentListener).documentChanged(getDocument(), oldDocument);
+			}
+		}
+	}
+
+	/**
+	 * Creates a new and empty document.
+	 * @return
+	 */
+	protected abstract D createDocument();
+
+	@Override
+	public void newDocument() {
+		setDocumentAndLocation(createDocument(), null);
+	}
+
+	/**
+	 * Creates an ImportStream from a location
+	 * @param location
+	 * @return
+	 * @throws IOException
+	 */
+	protected abstract InputStream toInputStream(L location) throws IOException;
+
+	protected InputStream toInputStream(final File location) throws IOException {
+		return new FileInputStream(location);
+	}
+
+	protected InputStream toInputStream(final URL location) throws IOException {
+		return location.openStream();
+	}
+
+	protected InputStream toInputStream(final URI location) throws IOException {
+		return toInputStream(location.toURL());
+	}
+
+	@Override
+	public void openDocument(final L storage) throws IOException {
+		try (InputStream input = toInputStream(storage)){
+			setDocumentAndLocation(loadDocument(input), storage);
+		} catch (final Exception e) {
+			throw new IOException(e);
+		}
+	}
+
+	@Override
+	public void saveDocument() throws IOException {
+		try {
+			saveDocument(getDocument(), getDocumentLocation());
+		} catch (final Exception e) {
+			throw new IOException(e);
+		}
+	}
+
+	public void saveDocumentAs(final L documentLocation) throws IOException {
+		final L oldDocumentLocation = getDocumentLocation();
+		setDocumentLocation(documentLocation);
+		try {
+			saveDocument();
+		} catch (final IOException e) {
+			setDocumentLocation(oldDocumentLocation);
+			throw e;
+		}
+	}
+
+	public void saveCopyAs(final L documentLocation) throws Exception {
+		saveDocument(getDocument(), documentLocation);
+	}
+}
diff --git a/simpleexample/src/main/java/fxutil/doc/FileMenuController.java b/simpleexample/src/main/java/fxutil/doc/FileMenuController.java
new file mode 100644
index 0000000000000000000000000000000000000000..3aef54e7f7fb4b6b11e3f2f6afa0c1085e753d80
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/FileMenuController.java
@@ -0,0 +1,220 @@
+package fxutil.doc;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javafx.event.ActionEvent;
+import javafx.fxml.FXML;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Alert.AlertType;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.Menu;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.TextInputDialog;
+import javafx.stage.FileChooser;
+
+public class FileMenuController {
+
+	private IDocumentStorage<File> documentStorage;
+
+	public void setDocumentStorage(final IDocumentStorage<File> documentStorage) {
+		this.documentStorage = documentStorage;
+		if (importMenu != null) {
+			importMenu.setDisable(documentStorage.getDocumentImporters().isEmpty());
+		}
+	}
+
+	@FXML
+	public void handleNewAction() {
+		documentStorage.newDocument();
+	}
+
+	private final List<File> recentFiles = new ArrayList<File>();
+
+	@FXML
+	private Menu recentMenu;
+
+	protected void updateRecentMenu(final File file) {
+		recentFiles.remove(file);
+		recentFiles.add(0, file);
+		recentMenu.getItems().clear();
+		for (final File recentFile : recentFiles) {
+			final MenuItem menuItem = new MenuItem();
+			menuItem.setText(recentFile.toString());
+			menuItem.setOnAction(event -> handleOpenAction(event));
+			recentMenu.getItems().add(menuItem);
+		}
+	}
+
+	private FileChooser fileChooser;
+
+	FileChooser getFileChooser() {
+		if (fileChooser == null) {
+			fileChooser = new FileChooser();
+		}
+		return fileChooser;
+	}
+
+	@FXML
+	public void handleOpenAction(final ActionEvent event) {
+		File selection = null;
+		if (event.getSource() instanceof MenuItem) {
+			final File file = new File(((MenuItem) event.getSource()).getText());
+			if (file.exists()) {
+				selection = file;
+			}
+		}
+		if (selection == null) {
+			final FileChooser fileChooser = getFileChooser();
+			selection = fileChooser.showOpenDialog(null);
+		}
+		if (selection != null) {
+			handleOpenAction(selection);
+		}
+	}
+
+	private void showExceptionDialog(final String message) {
+		final Alert alert = new Alert(AlertType.ERROR, message, ButtonType.CLOSE);
+		alert.showAndWait();
+	}
+
+	private void showExceptionDialog(final String message, final Exception e) {
+		showExceptionDialog(message + ": " + e.getLocalizedMessage());
+	}
+
+	void handleOpenAction(final File selection) {
+		try {
+			documentStorage.openDocument(selection);
+			updateRecentMenu(selection);
+		} catch (final IOException e) {
+			showExceptionDialog("Oops, problem when opening " + selection, e);
+		}
+	}
+
+	private void showSaveExceptionDialog(final File location, final Exception e) {
+		showExceptionDialog("Oops, problem saving to " + location, e);
+	}
+
+	@FXML
+	public void handleSaveAction() {
+		if (documentStorage.getDocumentLocation() == null) {
+			handleSaveAsAction();
+		} else {
+			try {
+				documentStorage.saveDocument();
+			} catch (final IOException e) {
+				showSaveExceptionDialog(documentStorage.getDocumentLocation(), e);
+			}
+		}
+	}
+
+	@FXML
+	public void handleSaveAsAction() {
+		final FileChooser fileChooser = getFileChooser();
+		final File selection = fileChooser.showSaveDialog(null);
+		handleSaveAsAction(selection);
+	}
+
+	void handleSaveAsAction(final File selection) {
+		final File oldStorage = documentStorage.getDocumentLocation();
+		try {
+			documentStorage.setDocumentLocation(selection);
+			documentStorage.saveDocument();
+		} catch (final IOException e) {
+			showSaveExceptionDialog(documentStorage.getDocumentLocation(), e);
+			documentStorage.setDocumentLocation(oldStorage);
+		}
+	}
+
+	@FXML
+	public void handleSaveCopyAsAction() {
+		final FileChooser fileChooser = getFileChooser();
+		final File selection = fileChooser.showSaveDialog(null);
+		handleSaveCopyAsAction(selection);
+	}
+
+	void handleSaveCopyAsAction(final File selection) {
+		final File oldStorage = documentStorage.getDocumentLocation();
+		try {
+			documentStorage.setDocumentLocation(selection);
+			documentStorage.saveDocument();
+		} catch (final IOException e) {
+			showSaveExceptionDialog(selection, e);
+		} finally {
+			documentStorage.setDocumentLocation(oldStorage);
+		}
+	}
+
+	@FXML
+	private Menu importMenu;
+
+	@FXML
+	public void handleFileImportAction() {
+		final FileChooser fileChooser = getFileChooser();
+		final File selection = fileChooser.showOpenDialog(null);
+		//		String path = selection.getPath();
+		//		int pos = path.lastIndexOf('.');
+		//		String ext = (pos > 0 ? path.substring(pos + 1) : null);
+		handleFileImportAction(selection);
+	}
+
+	void handleFileImportAction(final File selection) {
+		for (final IDocumentImporter importer : documentStorage.getDocumentImporters()) {
+			try (InputStream input = new FileInputStream(selection)) {
+				importer.importDocument(input);
+				break;
+			} catch (final Exception e) {
+			}
+		}
+	}
+
+	private TextInputDialog inputDialog;
+
+	@FXML
+	public void handleURLImportAction() {
+		if (inputDialog == null) {
+			inputDialog = new TextInputDialog();
+		}
+		inputDialog.setTitle("Import from URL");
+		inputDialog.setHeaderText("Enter URL to import from");
+		inputDialog.setContentText("Enter URL: ");
+		// https://developer.garmin.com/downloads/connect-api/sample_file.gpx
+		URL url = null;
+		while (url == null) {
+			final Optional<String> result = inputDialog.showAndWait();
+			if (! result.isPresent()) {
+				return;
+			}
+			try {
+				url = new URL(result.get());
+				if (handleURLImportAction(url)) {
+					return;
+				}
+				url = null;
+				inputDialog.setHeaderText("Problems reading it...");
+				inputDialog.setContentText("Enter another URL: ");
+			} catch (final MalformedURLException e1) {
+				inputDialog.setContentText("Enter a valid URL: ");
+			}
+		}
+	}
+
+	boolean handleURLImportAction(final URL url) {
+		for (final IDocumentImporter importer : documentStorage.getDocumentImporters()) {
+			try (InputStream input = url.openStream()) {
+				importer.importDocument(input);
+				return true;
+			} catch (final Exception e) {
+				System.err.println(e.getMessage());
+			}
+		}
+		return false;
+	}
+}
diff --git a/simpleexample/src/main/java/fxutil/doc/IDocumentImporter.java b/simpleexample/src/main/java/fxutil/doc/IDocumentImporter.java
new file mode 100644
index 0000000000000000000000000000000000000000..be436c3cbe3f43695088b3c491a82736b933b9a9
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/IDocumentImporter.java
@@ -0,0 +1,19 @@
+package fxutil.doc;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * An interface with a method for importing domain data from a location.
+ * The main use is supporting an **import** action in a **File** menu.
+ * @author hal
+ *
+ */
+public interface IDocumentImporter {
+	/**
+	 * Loads a document from the input stream and sets it as the current document.
+	 * @param inputStream
+	 * @throws IOException
+	 */
+	public void importDocument(InputStream inputStream) throws IOException;
+}
diff --git a/simpleexample/src/main/java/fxutil/doc/IDocumentListener.java b/simpleexample/src/main/java/fxutil/doc/IDocumentListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..ec6378199eafaa7d77688ca74586ed0f6867ab99
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/IDocumentListener.java
@@ -0,0 +1,18 @@
+package fxutil.doc;
+
+/**
+ * Listener interface for the (contents of) the (current) document of an IDocumentStorage, e.g.
+ * when an **open** action is performed.
+ * @author hal
+ *
+ * @param <D> the document type
+ * @param <L> the location type
+ */
+public interface IDocumentListener<D, L> extends IDocumentStorageListener<L> {
+	/**
+	 * Notifies that the current document has changed.
+	 * @param document the new document
+	 * @param oldDocument the previous document
+	 */
+	public void documentChanged(D document, D oldDocument);
+}
diff --git a/simpleexample/src/main/java/fxutil/doc/IDocumentLoader.java b/simpleexample/src/main/java/fxutil/doc/IDocumentLoader.java
new file mode 100644
index 0000000000000000000000000000000000000000..69fef493bc019159108a1874fda5c2f7bd7042c0
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/IDocumentLoader.java
@@ -0,0 +1,20 @@
+package fxutil.doc;
+
+import java.io.InputStream;
+
+/**
+ * An interface with a method for loading and returning a document (domain data container) from an InputStream.
+ * This allows various ways of loading or importing domain data, with different sources and formats.
+ * @author hal
+ *
+ * @param <D> the document type
+ */
+public interface IDocumentLoader<D> {
+	/**
+	 * Loads and returns a new document from an InputStream
+	 * @param inputStream
+	 * @return
+	 * @throws Exception
+	 */
+	public D loadDocument(InputStream inputStream) throws Exception;
+}
diff --git a/simpleexample/src/main/java/fxutil/doc/IDocumentPersistence.java b/simpleexample/src/main/java/fxutil/doc/IDocumentPersistence.java
new file mode 100644
index 0000000000000000000000000000000000000000..d53b8dd1da4a74fd5aa19688095b2509fbd55ac0
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/IDocumentPersistence.java
@@ -0,0 +1,4 @@
+package fxutil.doc;
+
+public interface IDocumentPersistence<D, L> extends IDocumentLoader<D>, IDocumentSaver<D, L> {
+}
diff --git a/simpleexample/src/main/java/fxutil/doc/IDocumentSaver.java b/simpleexample/src/main/java/fxutil/doc/IDocumentSaver.java
new file mode 100644
index 0000000000000000000000000000000000000000..6754304334e9b9d69b385bbdf04b5f23feafb964
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/IDocumentSaver.java
@@ -0,0 +1,19 @@
+package fxutil.doc;
+
+/**
+ * An interface with a method for saving a document (domain data container) to a location.
+ * This allows various ways of saving or exporting domain data, to different locations and formats.
+ * @author hal
+ *
+ * @param <D> the document type
+ * @param <L> the location type
+ */
+public interface IDocumentSaver<D, L> {
+	/**
+	 * Saves the provided document to the provided location
+	 * @param document
+	 * @param documentLocation
+	 * @throws Exception
+	 */
+	public void saveDocument(D document, L documentLocation) throws Exception;
+}
diff --git a/simpleexample/src/main/java/fxutil/doc/IDocumentStorage.java b/simpleexample/src/main/java/fxutil/doc/IDocumentStorage.java
new file mode 100644
index 0000000000000000000000000000000000000000..698c15803b1e7e6b127b117d9a54a9515f56b0df
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/IDocumentStorage.java
@@ -0,0 +1,59 @@
+package fxutil.doc;
+
+import java.io.IOException;
+import java.util.Collection;
+
+/**
+ * An interface with the methods necessary for supporting the standard File menu actions.
+ * The class representing the document (domain data container) is implicit in the implementation of this interface.
+ * The interface includes methods for getting and setting the location and creating, opening and saving the (current) document.
+ * @author hal
+ *
+ * @param <L> The type of the location, typically java.io.File.
+ */
+public interface IDocumentStorage<L> {
+	/**
+	 * Returns the current location (of the current document).
+	 * @return the current location
+	 */
+	public L getDocumentLocation();
+
+	/**
+	 * Sets the current location (of the current document), can be used by a save-as action.
+	 * @param documentLocation
+	 */
+	public void setDocumentLocation(L documentLocation);
+
+	/**
+	 * Adds an IDocumentStorageListener that will be notified when the current location changes.
+	 * @param documentStorageListener
+	 */
+
+	public void addDocumentStorageListener(IDocumentStorageListener<L> documentStorageListener);
+	/**
+	 * Removes an IDocumentStorageListener.
+	 * @param documentStorageListener
+	 */
+	public void removeDocumentStorageListener(IDocumentStorageListener<L> documentStorageListener);
+
+	/**
+	 * Creates a new documents and sets it as the current one, can be used by a new action.
+	 */
+	public void newDocument();
+
+	/**
+	 * Loads a documents from the provided location and sets it as the current one, can be used by an open action.
+	 */
+	public void openDocument(L documentLocation) throws IOException;
+
+	/**
+	 * Saves the current document (to the current location), can be used by a save action.
+	 */
+	public void saveDocument() throws IOException;
+
+	/**
+	 * Returns the set of IDocumentImporters, can be used by an import action.
+	 * @return
+	 */
+	public Collection<IDocumentImporter> getDocumentImporters();
+}
diff --git a/simpleexample/src/main/java/fxutil/doc/IDocumentStorageListener.java b/simpleexample/src/main/java/fxutil/doc/IDocumentStorageListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..d781f1c39af5c769d4073bd489b02261bda35633
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/IDocumentStorageListener.java
@@ -0,0 +1,17 @@
+package fxutil.doc;
+
+/**
+ * Listener interface for the (current) location of the (current) document of an IDocumentStorage, e.g.
+ * when a **save-as** action is performed.
+ * @author hal
+ *
+ * @param <L>
+ */
+public interface IDocumentStorageListener<L> {
+	/**
+	 * Notifies that the current document location has changed.
+	 * @param documentLocation the new document location
+	 * @param oldDocumentLocation the previous document location
+	 */
+	public void documentLocationChanged(L documentLocation, L oldDocumentLocation);
+}
diff --git a/simpleexample/src/main/java/fxutil/doc/SimpleJsonFileStorageImpl.java b/simpleexample/src/main/java/fxutil/doc/SimpleJsonFileStorageImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..cf35d0f19ae5d942aebdc3aee3dc709165875d7f
--- /dev/null
+++ b/simpleexample/src/main/java/fxutil/doc/SimpleJsonFileStorageImpl.java
@@ -0,0 +1,65 @@
+package fxutil.doc;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.Collections;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+
+public abstract class SimpleJsonFileStorageImpl<T> extends AbstractDocumentStorageImpl<T, File> implements IDocumentStorage<File> {
+
+	private T document;
+
+	private final ObjectMapper objectMapper;
+
+	private final Class<T> documentClass;
+
+	public SimpleJsonFileStorageImpl(final Class<T> documentClass) {
+		this.documentClass = documentClass;
+		final SimpleModule module = new SimpleModule();
+		configureJacksonModule(module);
+		objectMapper = new ObjectMapper();
+		objectMapper.registerModule(module);
+	}
+
+	protected abstract void configureJacksonModule(final SimpleModule module);
+
+	@Override
+	public T loadDocument(final InputStream inputStream) throws Exception {
+		return objectMapper.readValue(inputStream, documentClass);
+	}
+
+	@Override
+	public void saveDocument(final T document, final File documentLocation) throws Exception {
+		objectMapper.writeValue(new FileOutputStream(documentLocation, false), document);
+	}
+
+	@Override
+	public Collection<IDocumentImporter> getDocumentImporters() {
+		return Collections.emptyList();
+	}
+
+	@Override
+	public T getDocument() {
+		return document;
+	}
+
+	@Override
+	public void setDocument(final T document) {
+		final T oldDocument = this.document;
+		this.document = document;
+		fireDocumentChanged(oldDocument);
+	}
+
+	@Override
+	protected T createDocument() {
+		try {
+			return documentClass.getDeclaredConstructor().newInstance();
+		} catch (final Exception e) {
+			throw new RuntimeException("Exception when instantiating " + documentClass, e);
+		}
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/core/LatLong.java b/simpleexample/src/main/java/simpleex/core/LatLong.java
new file mode 100644
index 0000000000000000000000000000000000000000..74fa119dbda10a73cc03acf6054ad9147938db1a
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/core/LatLong.java
@@ -0,0 +1,118 @@
+package simpleex.core;
+
+public class LatLong {
+
+	public final double latitude, longitude;
+
+	public LatLong(final double latitude, final double longitude) {
+		super();
+		this.latitude = latitude;
+		this.longitude = longitude;
+	}
+
+	public final static String SEPARATOR = ",";
+
+	@Override
+	public String toString() {
+		return latitude + SEPARATOR + longitude;
+	}
+
+	@Override
+	public int hashCode() {
+		final int prime = 31;
+		int result = 1;
+		long temp;
+		temp = Double.doubleToLongBits(latitude);
+		result = prime * result + (int) (temp ^ (temp >>> 32));
+		temp = Double.doubleToLongBits(longitude);
+		result = prime * result + (int) (temp ^ (temp >>> 32));
+		return result;
+	}
+
+	@Override
+	public boolean equals(final Object obj) {
+		if (this == obj) {
+			return true;
+		}
+		if (obj == null || getClass() != obj.getClass()) {
+			return false;
+		}
+		final LatLong other = (LatLong) obj;
+		return (Double.doubleToLongBits(latitude) == Double.doubleToLongBits(other.latitude) &&
+				Double.doubleToLongBits(longitude) == Double.doubleToLongBits(other.longitude));
+	}
+
+	public static LatLong valueOf(final String s) {
+		return valueOf(s, SEPARATOR);
+	}
+
+	public static LatLong valueOf(final String s, final String sep) {
+		final int pos = s.indexOf(sep);
+		if (pos < 0) {
+			throw new IllegalArgumentException("No '" + sep + "' in "  + s);
+		}
+		final double lat = Double.valueOf(s.substring(0, pos).trim());
+		final double lon = Double.valueOf(s.substring(pos + sep.length()).trim());
+
+		return new LatLong(lat, lon);
+	}
+
+	/*::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::*/
+	/*::                                                                         :*/
+	/*::  This routine calculates the distance between two points (given the     :*/
+	/*::  latitude/longitude of those points). It is being used to calculate     :*/
+	/*::  the distance between two locations using GeoDataSource (TM) products   :*/
+	/*::                                                                         :*/
+	/*::  Definitions:                                                           :*/
+	/*::    South latitudes are negative, east longitudes are positive           :*/
+	/*::                                                                         :*/
+	/*::  Passed to function:                                                    :*/
+	/*::    lat1, lon1 = Latitude and Longitude of point 1 (in decimal degrees)  :*/
+	/*::    lat2, lon2 = Latitude and Longitude of point 2 (in decimal degrees)  :*/
+	/*::  Worldwide cities and other features databases with latitude longitude  :*/
+	/*::  are available at http://www.geodatasource.com                          :*/
+	/*::                                                                         :*/
+	/*::  For enquiries, please contact sales@geodatasource.com                  :*/
+	/*::                                                                         :*/
+	/*::  Official Web site: http://www.geodatasource.com                        :*/
+	/*::                                                                         :*/
+	/*::           GeoDataSource.com (C) All Rights Reserved 2015                :*/
+	/*::                                                                         :*/
+	/*::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::*/
+	public static double distance(final double lat1, final double lon1, final double lat2, final double lon2) {
+		if (lon1 == lon2 && lat1 == lat2) {
+			return 0.0;
+		}
+		final double theta = lon1 - lon2;
+		double dist = Math.sin(deg2rad(lat1)) * Math.sin(deg2rad(lat2)) + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.cos(deg2rad(theta));
+		dist = Math.acos(dist);
+		// convert to degrees
+		dist = rad2deg(dist);
+		dist = dist * 60 * 1.1515;
+		// convert to meters
+		dist = dist * 1609.344;
+		return dist;
+	}
+
+	public static double distance(final LatLong latLong1, final LatLong latLong2) {
+		return distance(latLong1.latitude, latLong1.longitude, latLong2.latitude, latLong2.longitude);
+	}
+
+	public double distance(final LatLong latLong2) {
+		return distance(latitude, longitude, latLong2.latitude, latLong2.longitude);
+	}
+
+	/*:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::*/
+	/*::	This function converts decimal degrees to radians						 :*/
+	/*:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::*/
+	private static double deg2rad(final double deg) {
+		return (deg * Math.PI / 180.0);
+	}
+
+	/*:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::*/
+	/*::	This function converts radians to decimal degrees						 :*/
+	/*:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::*/
+	private static double rad2deg(final double rad) {
+		return (rad * 180 / Math.PI);
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/core/LatLongs.java b/simpleexample/src/main/java/simpleex/core/LatLongs.java
new file mode 100644
index 0000000000000000000000000000000000000000..98f383795e9e2720d86d3436e9faffe779f6e8c5
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/core/LatLongs.java
@@ -0,0 +1,71 @@
+package simpleex.core;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+
+public class LatLongs implements Iterable<LatLong> {
+
+	final List<LatLong> latLongs = new ArrayList<>();
+
+	public LatLongs() {
+	}
+
+	public LatLongs(final double... latLongsArray) {
+		addLatLongs(latLongsArray);
+	}
+
+	public LatLongs(final LatLong... latLongs) {
+		addLatLongs(latLongs);
+	}
+
+	public LatLongs(final Collection<LatLong> latLongs) {
+		addLatLongs(latLongs);
+	}
+
+	@Override
+	public Iterator<LatLong> iterator() {
+		return latLongs.iterator();
+	}
+
+	public int getLatLongCount() {
+		return latLongs.size();
+	}
+
+	public LatLong getLatLong(final int num) {
+		return latLongs.get(num);
+	}
+
+	public void setLatLong(final int num, final LatLong latLong) {
+		latLongs.set(num, latLong);
+	}
+
+	public int addLatLong(final LatLong latLong) {
+		final int pos = latLongs.size();
+		latLongs.add(latLong);
+		return pos;
+	}
+
+	public int addLatLongs(final Collection<LatLong> latLongs) {
+		final int pos = this.latLongs.size();
+		this.latLongs.addAll(latLongs);
+		return pos;
+	}
+
+	public int addLatLongs(final LatLong... latLongs) {
+		return addLatLongs(List.of(latLongs));
+	}
+
+	public int addLatLongs(final double... latLongsArray) {
+		final Collection<LatLong> latLongs = new ArrayList<>(latLongsArray.length / 2);
+		for (int i = 0; i < latLongsArray.length; i += 2) {
+			latLongs.add(new LatLong(latLongsArray[i], latLongsArray[i + 1]));
+		}
+		return addLatLongs(latLongs);
+	}
+
+	public LatLong removeLatLong(final int num) {
+		return latLongs.remove(num);
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/core/README.md b/simpleexample/src/main/java/simpleex/core/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..834b0e8c9b764b22b851ef9d201a5c6d5bcec881
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/core/README.md
@@ -0,0 +1 @@
+# Source package for core code
\ No newline at end of file
diff --git a/simpleexample/src/main/java/simpleex/json/LatLongDeserializer.java b/simpleexample/src/main/java/simpleex/json/LatLongDeserializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..98519525e322e06668faba0c814fc3fdf1f3b955
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/json/LatLongDeserializer.java
@@ -0,0 +1,39 @@
+package simpleex.json;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import simpleex.core.LatLong;
+
+public class LatLongDeserializer extends JsonDeserializer<LatLong> {
+
+	@Override
+	public LatLong deserialize(final JsonParser jsonParser, final DeserializationContext deserContext) throws IOException, JsonProcessingException {
+		final JsonNode jsonNode = jsonParser.getCodec().readTree(jsonParser);
+		return deserialize(jsonNode);
+	}
+
+	public LatLong deserialize(final JsonNode jsonNode) throws JsonProcessingException {
+		if (jsonNode instanceof ObjectNode) {
+			final ObjectNode objectNode = (ObjectNode) jsonNode;
+			final double latitude = objectNode.get(LatLongSerializer.LATITUDE_FIELD_NAME).asDouble();
+			final double longitude = objectNode.get(LatLongSerializer.LONGITUDE_FIELD_NAME).asDouble();
+			return new LatLong(latitude, longitude);
+		} else if (jsonNode instanceof ArrayNode) {
+			final ArrayNode locationArray = (ArrayNode) jsonNode;
+			if (locationArray.size() == 2) {
+				final double latitude = locationArray.get(0).asDouble();
+				final double longitude = locationArray.get(1).asDouble();
+				return new LatLong(latitude, longitude);
+			}
+		}
+		return null;
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/json/LatLongSerializer.java b/simpleexample/src/main/java/simpleex/json/LatLongSerializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..5c939601ab8e83f5095c7511caf38c9b97f32851
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/json/LatLongSerializer.java
@@ -0,0 +1,25 @@
+package simpleex.json;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+import simpleex.core.LatLong;
+
+public class LatLongSerializer extends JsonSerializer<LatLong> {
+
+	public static final String LONGITUDE_FIELD_NAME = "longitude";
+	public static final String LATITUDE_FIELD_NAME = "latitude";
+
+	@Override
+	public void serialize(final LatLong latLon, final JsonGenerator jsonGen, final SerializerProvider provider) throws IOException {
+		jsonGen.writeStartObject();
+		jsonGen.writeFieldName(LATITUDE_FIELD_NAME);
+		jsonGen.writeNumber(latLon.latitude);
+		jsonGen.writeFieldName(LONGITUDE_FIELD_NAME);
+		jsonGen.writeNumber(latLon.longitude);
+		jsonGen.writeEndObject();
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/json/LatLongsDeserializer.java b/simpleexample/src/main/java/simpleex/json/LatLongsDeserializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..41232de2c15117ea36cfddf97b1e1d230c7e663a
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/json/LatLongsDeserializer.java
@@ -0,0 +1,35 @@
+package simpleex.json;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+
+import simpleex.core.LatLong;
+import simpleex.core.LatLongs;
+
+public class LatLongsDeserializer extends JsonDeserializer<LatLongs> {
+
+	private final LatLongDeserializer latLongDeserializer = new LatLongDeserializer();
+
+	@Override
+	public LatLongs deserialize(final JsonParser jsonParser, final DeserializationContext deserContext) throws IOException, JsonProcessingException {
+		final JsonNode jsonNode = jsonParser.getCodec().readTree(jsonParser);
+		if (jsonNode instanceof ArrayNode) {
+			final ArrayNode latLongsArray = (ArrayNode) jsonNode;
+			final Collection<LatLong> latLongs = new ArrayList<>(latLongsArray.size());
+			for (final JsonNode latLongNode : latLongsArray) {
+				final LatLong latLong = latLongDeserializer.deserialize(latLongNode);
+				latLongs.add(latLong);
+			}
+			return new LatLongs(latLongs);
+		}
+		return null;
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/json/LatLongsSerializer.java b/simpleexample/src/main/java/simpleex/json/LatLongsSerializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..d39fec5af1160eeb25dd635f42d86e2294c01090
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/json/LatLongsSerializer.java
@@ -0,0 +1,22 @@
+package simpleex.json;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+import simpleex.core.LatLong;
+import simpleex.core.LatLongs;
+
+public class LatLongsSerializer extends JsonSerializer<LatLongs> {
+
+	@Override
+	public void serialize(final LatLongs latLongs, final JsonGenerator jsonGen, final SerializerProvider provider) throws IOException {
+		jsonGen.writeStartArray(latLongs.getLatLongCount());
+		for (final LatLong latLong : latLongs) {
+			jsonGen.writeObject(latLong);
+		}
+		jsonGen.writeEndArray();
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/json/README.md b/simpleexample/src/main/java/simpleex/json/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..eb7378f5a08b201e32c2acac522d060cdc3df28c
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/json/README.md
@@ -0,0 +1 @@
+# Resources used by the web server code
\ No newline at end of file
diff --git a/simpleexample/src/main/java/simpleex/ui/DraggableNodeController.java b/simpleexample/src/main/java/simpleex/ui/DraggableNodeController.java
new file mode 100644
index 0000000000000000000000000000000000000000..4be472fe2e6386fc17851100881eae0186b457ab
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/ui/DraggableNodeController.java
@@ -0,0 +1,95 @@
+package simpleex.ui;
+
+import javafx.event.EventHandler;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.input.MouseEvent;
+
+public class DraggableNodeController {
+
+	public DraggableNodeController() {
+	}
+
+	public DraggableNodeController(final NodeDraggedHandler nodeDraggedHandler) {
+		setNodeDraggedHandler(nodeDraggedHandler);
+	}
+
+	private NodeDraggedHandler nodeDraggedHandler;
+
+	public void setNodeDraggedHandler(final NodeDraggedHandler nodeDraggedHandler) {
+		this.nodeDraggedHandler = nodeDraggedHandler;
+	}
+
+	private boolean immediate = false;
+
+	public void setImmediate(final boolean immediate) {
+		this.immediate = immediate;
+	}
+
+	private Node currentNode = null;
+	private Point2D startPoint = null;
+	private Point2D startTranslate = null;
+
+	private final EventHandler<MouseEvent> mousePressedHandler = this::mousePressed;
+	private final EventHandler<MouseEvent> mouseDraggedHandler = this::mouseDragged;
+	private final EventHandler<MouseEvent> mouseReleasedHandler = this::mouseReleased;
+
+	public void attach(final Node node) {
+		node.setOnMousePressed(mousePressedHandler);
+		node.setOnMouseDragged(mouseDraggedHandler);
+		node.setOnMouseReleased(mouseReleasedHandler);
+	}
+
+	public void detach(final Node node) {
+		node.setOnMousePressed(null);
+		node.setOnMouseDragged(null);
+		node.setOnMouseReleased(null);
+	}
+
+	private void mousePressed(final MouseEvent mouseEvent) {
+		if (currentNode == null && mouseEvent.getSource() instanceof Node) {
+			currentNode = (Node) mouseEvent.getSource();
+			startPoint = new Point2D(mouseEvent.getSceneX(), mouseEvent.getSceneY());
+			startTranslate = new Point2D(currentNode.getTranslateX(), currentNode.getTranslateY());
+			mouseEvent.consume();
+		}
+	}
+
+	private void mouseDragged(final MouseEvent mouseEvent) {
+		if (currentNode != null && currentNode == mouseEvent.getSource()) {
+			final double dx = mouseEvent.getSceneX() - startPoint.getX();
+			final double dy = mouseEvent.getSceneY() - startPoint.getY();
+			updateNode(dx, dy);
+		}
+	}
+
+	protected void updateNode(final double dx, final double dy) {
+		if (immediate && nodeDraggedHandler != null) {
+			nodeDraggedHandler.nodeDragged(currentNode, dx, dy);
+			startPoint = startPoint.add(dx, dy);
+		} else {
+			currentNode.setTranslateX(startTranslate.getX() + dx);
+			currentNode.setTranslateY(startTranslate.getY() + dy);
+		}
+	}
+
+	private void mouseReleased(final MouseEvent mouseEvent) {
+		if (currentNode != null && currentNode == mouseEvent.getSource()) {
+			final double dx = mouseEvent.getSceneX() - startPoint.getX();
+			final double dy = mouseEvent.getSceneY() - startPoint.getY();
+			if (! immediate) {
+				currentNode.setTranslateX(startTranslate.getX());
+				currentNode.setTranslateY(startTranslate.getY());
+			}
+			final Node node = currentNode;
+			currentNode = null;
+			if (nodeDraggedHandler != null) {
+				nodeDraggedHandler.nodeDragged(node, dx, dy);
+			}
+		}
+	}
+
+	public interface NodeDraggedHandler {
+		public void nodeDragged(Node currentNode2, double dx, double dy);
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/ui/FxApp.java b/simpleexample/src/main/java/simpleex/ui/FxApp.java
new file mode 100644
index 0000000000000000000000000000000000000000..5ab81835d07744d84d0c26cc5c0a6dc09e35988f
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/ui/FxApp.java
@@ -0,0 +1,31 @@
+package simpleex.ui;
+
+import javafx.application.Application;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.stage.Stage;
+import simpleex.core.LatLongs;
+
+public class FxApp extends Application {
+
+	@Override
+	public void start(final Stage stage) throws Exception {
+		final FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("FxApp.fxml"));
+		final Parent root = fxmlLoader.load();
+		final FxAppController controller = fxmlLoader.getController();
+		controller.setLatLongs(new LatLongs(63.1, 11.2, 63.2, 11.0));
+		final Scene scene = new Scene(root);
+		stage.setScene(scene);
+		stage.show();
+	}
+
+	public static void main(final String[] args) {
+		// only needed on ios
+		System.setProperty("os.target", "ios");
+		System.setProperty("os.name", "iOS");
+		System.setProperty("glass.platform", "ios");
+		System.setProperty("targetos.name", "iOS");
+		launch(args);
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/ui/FxAppController.java b/simpleexample/src/main/java/simpleex/ui/FxAppController.java
new file mode 100644
index 0000000000000000000000000000000000000000..ab1bc326609a5faf0cd99d8d20453ec4caa62590
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/ui/FxAppController.java
@@ -0,0 +1,163 @@
+package simpleex.ui;
+
+import java.io.File;
+
+import fxmapcontrol.Location;
+import fxmapcontrol.MapBase;
+import fxmapcontrol.MapItemsControl;
+import fxmapcontrol.MapNode;
+import fxmapcontrol.MapProjection;
+import fxutil.doc.FileMenuController;
+import fxutil.doc.IDocumentListener;
+import javafx.collections.FXCollections;
+import javafx.fxml.FXML;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.control.ListView;
+import javafx.scene.control.Slider;
+import simpleex.core.LatLong;
+import simpleex.core.LatLongs;
+
+public class FxAppController extends FileMenuController implements IDocumentListener<LatLongs, File> {
+
+	private final LatLongsStorage latLongsStorage;
+
+	public FxAppController() {
+		latLongsStorage = new LatLongsStorage();
+		latLongsStorage.addDocumentStorageListener(this);
+	}
+
+	public LatLongs getLatLongs() {
+		return latLongsStorage.getDocument();
+	}
+
+	// to make it testable
+	public void setLatLongs(final LatLongs latLongs) {
+		latLongsStorage.setDocument(latLongs);
+		updateLocationViewList(0);
+	}
+
+	//	@FXML
+	//	private FileMenuController fileMenuController;
+
+	@FXML
+	private ListView<LatLong> locationListView;
+
+	@FXML
+	private MapBase mapView;
+
+	private MapItemsControl<MapNode> markersParent;
+	private MapMarker marker = null;
+	private DraggableNodeController draggableMapController = null;
+	private DraggableNodeController draggableMarkerController = null;
+
+	@FXML
+	private Slider zoomSlider;
+
+	@FXML
+	private void initialize() {
+		//		fileMenuController.
+		setDocumentStorage(latLongsStorage);
+		// map stuff
+		// mapView.getChildren().add(MapTileLayer.getOpenStreetMapLayer());
+		zoomSlider.valueProperty().addListener((prop, oldValue, newValue) -> mapView.setZoomLevel(zoomSlider.getValue()));
+		zoomSlider.setValue(8);
+		markersParent = new MapItemsControl<MapNode>();
+		mapView.getChildren().add(markersParent);
+		draggableMapController = new DraggableNodeController(this::handleMapDragged);
+		draggableMapController.setImmediate(true);
+		draggableMapController.attach(mapView);
+		draggableMarkerController = new DraggableNodeController(this::handleMarkerDragged);
+		// the location list
+		locationListView.getSelectionModel().selectedIndexProperty().addListener((prop, oldValue, newValue) -> updateMapMarker(true));
+	}
+
+	private void handleMapDragged(final Node node, final double dx, final double dy) {
+		final MapProjection projection = mapView.getProjection();
+		final Point2D point = projection.locationToViewportPoint(mapView.getCenter());
+		final Location newCenter = projection.viewportPointToLocation(point.add(-dx, -dy));
+		mapView.setCenter(newCenter);
+	}
+
+	private void handleMarkerDragged(final Node node, final double dx, final double dy) {
+		final MapProjection projection = mapView.getProjection();
+		final Point2D point = projection.locationToViewportPoint(marker.getLocation());
+		final Location newLocation = projection.viewportPointToLocation(point.add(dx, dy));
+		getLatLongs().setLatLong(locationListView.getSelectionModel().getSelectedIndex(), location2LatLong(newLocation));
+		updateLocationViewListSelection(false);
+	}
+
+	private LatLong location2LatLong(final Location newLocation) {
+		return new LatLong(newLocation.getLatitude(), newLocation.getLongitude());
+	}
+
+	private void updateMapMarker(final boolean centerOnMarker) {
+		final int num = locationListView.getSelectionModel().getSelectedIndex();
+		if (num < 0 || num >= getLatLongs().getLatLongCount()) {
+			markersParent.getItems().clear();
+			if (draggableMarkerController != null) {
+				draggableMarkerController.detach(marker);
+			}
+			marker = null;
+		} else {
+			final LatLong latLong = getLatLongs().getLatLong(num);
+			if (marker == null) {
+				marker = new MapMarker(latLong);
+				markersParent.getItems().add(marker);
+				if (draggableMarkerController != null) {
+					draggableMarkerController.attach(marker);
+				}
+			} else {
+				marker.setLocation(latLong);
+			}
+			if (centerOnMarker) {
+				mapView.setCenter(marker.getLocation());
+			}
+		}
+	}
+
+	@FXML
+	private void handleAddLocation() {
+		final Location center = mapView.getCenter();
+		final int pos = getLatLongs().addLatLong(location2LatLong(center));
+		updateLocationViewList(pos);
+	}
+
+	private void updateLocationViewListSelection(final Boolean updateMapMarker) {
+		final int selectedIndex = locationListView.getSelectionModel().getSelectedIndex();
+		updateLocationViewListItem(selectedIndex);
+		if (updateMapMarker != null) {
+			updateMapMarker(updateMapMarker);
+		}
+	}
+
+	private void updateLocationViewListItem(final int index) {
+		locationListView.getItems().set(index, getLatLongs().getLatLong(index));
+	}
+
+	private void updateLocationViewList(int selectedIndex) {
+		final LatLong[] latLongs = new LatLong[getLatLongs().getLatLongCount()];
+		for (int i = 0; i < latLongs.length; i++) {
+			latLongs[i] = getLatLongs().getLatLong(i);
+		}
+		final int oldSelectionIndex = locationListView.getSelectionModel().getSelectedIndex();
+		locationListView.setItems(FXCollections.observableArrayList(latLongs));
+		if (selectedIndex < 0 || selectedIndex >= latLongs.length) {
+			selectedIndex = oldSelectionIndex;
+		}
+		if (selectedIndex >= 0 && selectedIndex < getLatLongs().getLatLongCount()) {
+			locationListView.getSelectionModel().select(selectedIndex);
+		}
+	}
+
+	// IDocumentListener
+
+	@Override
+	public void documentLocationChanged(final File documentLocation, final File oldDocumentLocation) {
+	}
+
+	@Override
+	public void documentChanged(final LatLongs document, final LatLongs oldDocument) {
+		updateLocationViewList(0);
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/ui/LatLongsApp.java b/simpleexample/src/main/java/simpleex/ui/LatLongsApp.java
new file mode 100644
index 0000000000000000000000000000000000000000..6436b5d21cc9db32334d5f4055ffe61847d4db88
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/ui/LatLongsApp.java
@@ -0,0 +1,98 @@
+package simpleex.ui;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.stream.Collectors;
+
+import fxutil.doc.AbstractDocumentStorageImpl;
+import fxutil.doc.IDocumentImporter;
+import fxutil.doc.IDocumentLoader;
+import fxutil.doc.IDocumentPersistence;
+import fxutil.doc.IDocumentStorage;
+import simpleex.core.LatLongs;
+
+public class LatLongsApp {
+
+	private LatLongs latLongs = null;
+
+	private final IDocumentPersistence<LatLongs, File> documentPersistence = new IDocumentPersistence<LatLongs, File>() {
+
+		@Override
+		public LatLongs loadDocument(final InputStream inputStream) throws Exception {
+			// TODO
+			return null;
+		}
+
+		@Override
+		public void saveDocument(final LatLongs document, final File documentLocation) throws Exception {
+			try (OutputStream output = new FileOutputStream(documentLocation)) {
+				// TODO
+			}
+		}
+	};
+
+	private final AbstractDocumentStorageImpl<LatLongs, File> documentStorage = new AbstractDocumentStorageImpl<LatLongs, File>() {
+
+		@Override
+		protected LatLongs getDocument() {
+			return latLongs;
+		}
+
+		@Override
+		protected void setDocument(final LatLongs document) {
+			final LatLongs oldDocument = getDocument();
+			LatLongsApp.this.latLongs = document;
+			fireDocumentChanged(oldDocument);
+		}
+
+		@Override
+		protected LatLongs createDocument() {
+			return new LatLongs();
+		}
+
+		@Override
+		protected InputStream toInputStream(final File storage) throws IOException {
+			return new FileInputStream(storage);
+		}
+
+		@Override
+		public LatLongs loadDocument(final InputStream inputStream) throws Exception {
+			return documentPersistence.loadDocument(inputStream);
+		}
+
+		@Override
+		public void saveDocument(final LatLongs document, final File documentLocation) throws Exception {
+			documentPersistence.saveDocument(document, documentLocation);
+		}
+
+		@Override
+		public Collection<IDocumentImporter> getDocumentImporters() {
+			return documentLoaders.stream().map(loader -> new IDocumentImporter() {
+				@Override
+				public void importDocument(final InputStream inputStream) throws IOException {
+					try {
+						setDocumentAndLocation(loader.loadDocument(inputStream), null);
+					} catch (final Exception e) {
+						throw new IOException(e);
+					}
+				}
+			}).collect(Collectors.toList());
+		}
+	};
+
+	public IDocumentStorage<File> getDocumentStorage() {
+		return documentStorage;
+	}
+
+	private final Collection<IDocumentLoader<LatLongs>> documentLoaders = Arrays.asList();
+
+	public Iterable<IDocumentLoader<LatLongs>> getDocumentLoaders() {
+		return documentLoaders;
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/ui/LatLongsStorage.java b/simpleexample/src/main/java/simpleex/ui/LatLongsStorage.java
new file mode 100644
index 0000000000000000000000000000000000000000..53e108bf799ae1f70edecada4d0d7e27703b3a6b
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/ui/LatLongsStorage.java
@@ -0,0 +1,29 @@
+package simpleex.ui;
+
+import java.io.File;
+
+import com.fasterxml.jackson.databind.module.SimpleModule;
+
+import fxutil.doc.IDocumentStorage;
+import fxutil.doc.SimpleJsonFileStorageImpl;
+import simpleex.core.LatLong;
+import simpleex.core.LatLongs;
+import simpleex.json.LatLongDeserializer;
+import simpleex.json.LatLongSerializer;
+import simpleex.json.LatLongsDeserializer;
+import simpleex.json.LatLongsSerializer;
+
+public class LatLongsStorage extends SimpleJsonFileStorageImpl<LatLongs> implements IDocumentStorage<File> {
+
+	public LatLongsStorage() {
+		super(LatLongs.class);
+	}
+
+	@Override
+	protected void configureJacksonModule(final SimpleModule module) {
+		module.addSerializer(LatLong.class, new LatLongSerializer());
+		module.addSerializer(LatLongs.class, new LatLongsSerializer());
+		module.addDeserializer(LatLong.class, new LatLongDeserializer());
+		module.addDeserializer(LatLongs.class, new LatLongsDeserializer());
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/ui/MapMarker.java b/simpleexample/src/main/java/simpleex/ui/MapMarker.java
new file mode 100644
index 0000000000000000000000000000000000000000..f61ce55782dafe2da7155672778dccd12495eb25
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/ui/MapMarker.java
@@ -0,0 +1,22 @@
+package simpleex.ui;
+
+import fxmapcontrol.Location;
+import fxmapcontrol.MapItem;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.Circle;
+import simpleex.core.LatLong;
+
+public class MapMarker extends MapItem<LatLong> {
+
+	public MapMarker(final LatLong latLong) {
+		setLocation(latLong);
+		final Circle circle = new Circle();
+		circle.setRadius(5);
+		circle.setFill(Color.BLUE);
+		getChildren().add(circle);
+	}
+
+	public void setLocation(final LatLong latLong) {
+		setLocation(new Location(latLong.latitude, latLong.longitude));
+	}
+}
diff --git a/simpleexample/src/main/java/simpleex/ui/README.md b/simpleexample/src/main/java/simpleex/ui/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9b8ca7943de735a79f4661ae89a93f8aac366bc6
--- /dev/null
+++ b/simpleexample/src/main/java/simpleex/ui/README.md
@@ -0,0 +1 @@
+# The UI source code, a minimal JavaFX/FXML UI
\ No newline at end of file
diff --git a/simpleexample/src/main/resources/simpleex/ui/FileMenu.fxml b/simpleexample/src/main/resources/simpleex/ui/FileMenu.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..ffaf61d6c0e0da7bdfce6576780eef9508af61b4
--- /dev/null
+++ b/simpleexample/src/main/resources/simpleex/ui/FileMenu.fxml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import javafx.scene.control.Menu?>
+<?import javafx.scene.control.MenuItem?>
+<?import javafx.scene.control.SeparatorMenuItem?>
+
+<Menu xmlns:fx="http://javafx.com/fxml" text="File" fx:controller="fxutil.doc.FileMenuController">
+	<items>
+		<MenuItem text="New" 		accelerator="Meta+N" 	onAction="#handleNewAction"/>
+		<MenuItem text="Open..."  	accelerator="Meta+O"		onAction="#handleOpenAction"/>
+		<Menu fx:id="recentMenu" text="Open Recent"/>
+		<SeparatorMenuItem/>
+		<MenuItem text="Save"		accelerator="Meta+S"		onAction="#handleSaveAction"/>
+		<MenuItem text="Save As..." 							onAction="#handleSaveAsAction"/>
+		<MenuItem text="Save Copy As..." 					onAction="#handleSaveCopyAsAction"/>
+		<SeparatorMenuItem/>
+		<Menu fx:id="importMenu" text="Import">
+			<MenuItem text="File..." 						onAction="#handleFileImportAction"/>
+			<MenuItem text="URL..." 							onAction="#handleURLImportAction"/>
+		</Menu>
+	</items>
+</Menu>
diff --git a/simpleexample/src/main/resources/simpleex/ui/FxApp.fxml b/simpleexample/src/main/resources/simpleex/ui/FxApp.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..a2410ddb586006c8325e07568bdb2bb3d379902a
--- /dev/null
+++ b/simpleexample/src/main/resources/simpleex/ui/FxApp.fxml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import javafx.scene.layout.BorderPane?>
+<?import javafx.scene.layout.VBox?>
+<?import javafx.scene.control.ListView?>
+<?import javafx.scene.control.Slider?>
+<?import fxmapcontrol.MapBase?>
+<?import javafx.scene.control.Button?>
+<?import javafx.scene.layout.HBox?>
+<?import javafx.scene.control.TextField?>
+<?import javafx.scene.control.Label?>
+<?import fxmapcontrol.MapTileLayer?>
+<?import fxmapcontrol.TileSource?>
+<?import javafx.scene.control.MenuBar?>
+
+<?import javafx.scene.control.Menu?>
+<?import javafx.scene.control.MenuItem?>
+<?import javafx.scene.control.SeparatorMenuItem?>
+
+<BorderPane xmlns:fx="http://javafx.com/fxml"
+            fx:controller="simpleex.ui.FxAppController"
+            prefHeight="750" prefWidth="1000">
+    <top>
+		<VBox>
+			<MenuBar >
+				<menus>
+					<fx:include fx:id="fileMenu" source="FileMenu.fxml"/>
+				</menus>
+			</MenuBar>
+		</VBox>
+    </top>
+    <left>
+		<VBox fillWidth="true">
+			<ListView fx:id="locationListView"/>
+			<Button text="Add location" onAction="#handleAddLocation"/>
+		</VBox>
+	</left>
+	<center>
+		<VBox>
+			<MapBase fx:id="mapView">
+				<MapTileLayer name="OpenStreetMap" minZoomLevel="0" maxZoomLevel="17">
+					<tileSource>
+						<TileSource urlFormat="http://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png"/>
+					<!--
+						<TileSource urlFormat="https://{c}.tile.openstreetmap.org/{z}/{x}/{y}.png"/>
+						<TileSource urlFormat="http://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=topo2&amp;zoom={z}&amp;x={x}&amp;y={y}"/>
+						<TileSource urlFormat="http://mt1.google.com/vt/lyrs=m@129&amp;hl=en&amp;s=Galileo&amp;z={z}&amp;x={x}&amp;y={y}"/>
+					-->
+					</tileSource>
+				</MapTileLayer>
+			</MapBase>
+			<Slider fx:id="zoomSlider" min="1" max="20" value="9"/>
+		</VBox>
+	</center>
+</BorderPane>
diff --git a/simpleexample/src/test/java/simpleex/core/LatLongTest.java b/simpleexample/src/test/java/simpleex/core/LatLongTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..b6a333546f6e2a2ec5c537207243bd211edcda3e
--- /dev/null
+++ b/simpleexample/src/test/java/simpleex/core/LatLongTest.java
@@ -0,0 +1,32 @@
+package simpleex.core;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class LatLongTest {
+
+	@Test
+	public void testToString() {
+		final LatLong latLong = new LatLong(63.0, 10.0);
+		Assert.assertEquals(Double.toString(63) + "," + Double.toString(10), latLong.toString());
+	}
+
+	@Test
+	public void testValueOf() {
+		testLatLong(LatLong.valueOf("63.0, 10.0"), 63.0, 10.0);
+		testLatLong(LatLong.valueOf("63.0, 10.0", ","), 63.0, 10.0);
+		testLatLong(LatLong.valueOf("63.0; 10.0", ";"), 63.0, 10.0);
+	}
+
+	private void testLatLong(final LatLong latLong, final double lat, final double lon) {
+		Assert.assertEquals(lat, latLong.latitude, 0.0);
+		Assert.assertEquals(lon, latLong.longitude, 0.0);
+	}
+
+	@Test
+	public void testEquals() {
+		Assert.assertTrue(new LatLong(63.0, 10.0).equals(new LatLong(63.0, 10.0)));
+		Assert.assertFalse(new LatLong(10.0, 63.0).equals(new LatLong(63.0, 10.0)));
+		Assert.assertFalse(new LatLong(10.0, 63.0).equals(null));
+	}
+}
diff --git a/simpleexample/src/test/java/simpleex/core/LatLongsTest.java b/simpleexample/src/test/java/simpleex/core/LatLongsTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..0c7d4f2b5b3539d55682ed0ba2fe8b42f2fcfdc2
--- /dev/null
+++ b/simpleexample/src/test/java/simpleex/core/LatLongsTest.java
@@ -0,0 +1,22 @@
+package simpleex.core;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import simpleex.core.LatLongs;
+
+public class LatLongsTest {
+
+	private LatLongs latLongs;
+
+	@Before
+	public void setup() {
+		latLongs = new LatLongs();
+	}
+
+	@Test
+	public void testEmptyConstructor() {
+		Assert.assertEquals(0, latLongs.getLatLongCount());
+	}
+}
diff --git a/simpleexample/src/test/java/simpleex/json/AbstractJsonTest.java b/simpleexample/src/test/java/simpleex/json/AbstractJsonTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..cfbd5e65d7b90577bd4c3e16b436ac270ecaa630
--- /dev/null
+++ b/simpleexample/src/test/java/simpleex/json/AbstractJsonTest.java
@@ -0,0 +1,49 @@
+package simpleex.json;
+
+import java.io.IOException;
+
+import org.junit.Assert;
+
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+
+public abstract class AbstractJsonTest {
+
+	private ObjectMapper objectMapper;
+
+	public ObjectMapper getObjectMapper() {
+		return objectMapper;
+	}
+
+	public void setup() {
+		objectMapper = createObjectMapper();
+	}
+
+	protected abstract ObjectMapper createObjectMapper();
+
+	protected <T> SimpleModule createSimpleModule(final Class<T> clazz, final JsonSerializer<T> serializer, final JsonDeserializer<T> deserializer) {
+		return new SimpleModule()
+				.addSerializer(clazz, serializer)
+				.addDeserializer(clazz, deserializer);
+	}
+
+	protected <T> ObjectMapper createObjectMapper(final Class<T> clazz, final JsonSerializer<T> serializer, final JsonDeserializer<T> deserializer) {
+		return new ObjectMapper().registerModule(createSimpleModule(clazz, serializer, deserializer));
+	}
+
+	protected void assertEqualsIgnoreWhitespace(final String expected, final String actual) throws Exception {
+		Assert.assertEquals(expected, actual.replaceAll("\\s+",  ""));
+	}
+
+	protected void assertWriteValue(final String expected, final Object value) throws Exception {
+		assertEqualsIgnoreWhitespace(expected, getObjectMapper().writeValueAsString(value));
+	}
+
+	protected <T> T readValue(final String s, final Class<T> clazz) throws IOException, JsonParseException, JsonMappingException {
+		return getObjectMapper().readValue(s, clazz);
+	}
+}
diff --git a/simpleexample/src/test/java/simpleex/json/LatLongJsonTest.java b/simpleexample/src/test/java/simpleex/json/LatLongJsonTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..7e926a56687dfec28212631ab250a4708e49d4ef
--- /dev/null
+++ b/simpleexample/src/test/java/simpleex/json/LatLongJsonTest.java
@@ -0,0 +1,35 @@
+package simpleex.json;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import simpleex.core.LatLong;
+import simpleex.json.LatLongDeserializer;
+import simpleex.json.LatLongSerializer;
+
+public class LatLongJsonTest extends AbstractJsonTest {
+
+	@Before
+	@Override
+	public void setup() {
+		super.setup();
+	}
+
+	@Override
+	protected ObjectMapper createObjectMapper() {
+		return createObjectMapper(LatLong.class, new LatLongSerializer(), new LatLongDeserializer());
+	}
+
+	@Test
+	public void testLatLongSerialization() throws Exception {
+		assertWriteValue("{\"latitude\":63.1,\"longitude\":12.3}", new LatLong(63.1, 12.3));
+	}
+
+	@Test
+	public void testLatLongDeserialization() throws Exception {
+		Assert.assertEquals(new LatLong(63.1, 12.3), readValue("[ 63.1, 12.3 ]", LatLong.class));
+	}
+}
diff --git a/simpleexample/src/test/java/simpleex/ui/FxAppTest.java b/simpleexample/src/test/java/simpleex/ui/FxAppTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..fad97d30c37c873b035c4a6802f5de349d519d35
--- /dev/null
+++ b/simpleexample/src/test/java/simpleex/ui/FxAppTest.java
@@ -0,0 +1,109 @@
+package simpleex.ui;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.testfx.framework.junit.ApplicationTest;
+
+import fxmapcontrol.Location;
+import fxmapcontrol.MapBase;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.scene.control.Button;
+import javafx.scene.control.ListView;
+import javafx.stage.Stage;
+import simpleex.core.LatLong;
+import simpleex.core.LatLongs;
+
+public class FxAppTest extends ApplicationTest {
+
+	@BeforeClass
+	public static void headless() {
+		if (Boolean.valueOf(System.getProperty("gitlab-ci", "false"))) {
+			System.setProperty("prism.verbose", "true"); // optional
+			System.setProperty("java.awt.headless", "true");
+			System.setProperty("testfx.robot", "glass");
+			System.setProperty("testfx.headless", "true");
+			System.setProperty("glass.platform", "Monocle");
+			System.setProperty("monocle.platform", "Headless");
+			System.setProperty("prism.order", "sw");
+			System.setProperty("prism.text", "t2k");
+			System.setProperty("testfx.setup.timeout", "2500");
+		}
+	}
+
+	private FxAppController controller;
+	private LatLongs latLongs;
+
+	@Override
+	public void start(final Stage stage) throws Exception {
+		final FXMLLoader loader = new FXMLLoader(getClass().getResource("FxApp.fxml"));
+		final Parent root = loader.load();
+		this.controller = loader.getController();
+		setUpLatLongs();
+		final Scene scene = new Scene(root);
+		stage.setScene(scene);
+		stage.show();
+	}
+
+	private List<LatLong> latLongList;
+
+	private void setUpLatLongs() {
+		// test data
+		latLongList = new ArrayList<>(List.of(new LatLong(63.1, 11.2), new LatLong(63.2, 11.0)));
+		// "mocked" (faked) LatLongs object with very specific and limited behavior
+		latLongs = mock(LatLongs.class);
+		// get nth LatLong object
+		when(latLongs.getLatLong(anyInt())).then(invocation -> latLongList.get(invocation.getArgument(0)));
+		// get the number of LatLong objects
+		when(latLongs.getLatLongCount()).then(invocation -> latLongList.size());
+		// iterator for LatLong objects
+		when(latLongs.iterator()).then(invocation -> latLongList.iterator());
+		controller.setLatLongs(latLongs);
+	}
+
+	@Test
+	public void testController() {
+		Assert.assertTrue(this.controller instanceof FxAppController);
+	}
+
+	@Test
+	public void testLocationListView() {
+		final ListView<?> locationListView = lookup("#locationListView").query();
+		// list contains equals elements in same order
+		Assert.assertEquals(latLongList, locationListView.getItems());
+		// first list element is auto-selected
+		Assert.assertEquals(0, locationListView.getSelectionModel().getSelectedIndex());
+	}
+
+	@Test
+	public void testMapView() {
+		final MapBase mapView = lookup("#mapView").query();
+		// center of map view is approx. the first LatLong object
+		final Location center = mapView.getCenter();
+		final double epsilon = 0.000001; // round-off error
+		Assert.assertEquals(latLongList.get(0).latitude, center.getLatitude(), epsilon);
+		Assert.assertEquals(latLongList.get(0).longitude, center.getLongitude(), epsilon);
+	}
+
+	@Test
+	public void testAddLocation() {
+		// needs map center
+		final Location center = ((MapBase) lookup("#mapView").query()).getCenter();
+		// add behavior for add
+		final LatLong latLong = new LatLong(center.getLatitude(), center.getLongitude());
+		when(latLongs.addLatLong(latLong)).thenReturn(2); // add center
+
+		// make test less sensitive to exact button text
+		final Button addLocButton = lookup(node -> node instanceof Button && ((Button) node).getText().toLowerCase().startsWith("add loc")).query();
+		clickOn(addLocButton);
+	}
+}
diff --git a/simpleexample/src/test/resources/simpleex/ui/FileMenu.fxml b/simpleexample/src/test/resources/simpleex/ui/FileMenu.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..ffaf61d6c0e0da7bdfce6576780eef9508af61b4
--- /dev/null
+++ b/simpleexample/src/test/resources/simpleex/ui/FileMenu.fxml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import javafx.scene.control.Menu?>
+<?import javafx.scene.control.MenuItem?>
+<?import javafx.scene.control.SeparatorMenuItem?>
+
+<Menu xmlns:fx="http://javafx.com/fxml" text="File" fx:controller="fxutil.doc.FileMenuController">
+	<items>
+		<MenuItem text="New" 		accelerator="Meta+N" 	onAction="#handleNewAction"/>
+		<MenuItem text="Open..."  	accelerator="Meta+O"		onAction="#handleOpenAction"/>
+		<Menu fx:id="recentMenu" text="Open Recent"/>
+		<SeparatorMenuItem/>
+		<MenuItem text="Save"		accelerator="Meta+S"		onAction="#handleSaveAction"/>
+		<MenuItem text="Save As..." 							onAction="#handleSaveAsAction"/>
+		<MenuItem text="Save Copy As..." 					onAction="#handleSaveCopyAsAction"/>
+		<SeparatorMenuItem/>
+		<Menu fx:id="importMenu" text="Import">
+			<MenuItem text="File..." 						onAction="#handleFileImportAction"/>
+			<MenuItem text="URL..." 							onAction="#handleURLImportAction"/>
+		</Menu>
+	</items>
+</Menu>
diff --git a/simpleexample/src/test/resources/simpleex/ui/FxApp.fxml b/simpleexample/src/test/resources/simpleex/ui/FxApp.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..a2410ddb586006c8325e07568bdb2bb3d379902a
--- /dev/null
+++ b/simpleexample/src/test/resources/simpleex/ui/FxApp.fxml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import javafx.scene.layout.BorderPane?>
+<?import javafx.scene.layout.VBox?>
+<?import javafx.scene.control.ListView?>
+<?import javafx.scene.control.Slider?>
+<?import fxmapcontrol.MapBase?>
+<?import javafx.scene.control.Button?>
+<?import javafx.scene.layout.HBox?>
+<?import javafx.scene.control.TextField?>
+<?import javafx.scene.control.Label?>
+<?import fxmapcontrol.MapTileLayer?>
+<?import fxmapcontrol.TileSource?>
+<?import javafx.scene.control.MenuBar?>
+
+<?import javafx.scene.control.Menu?>
+<?import javafx.scene.control.MenuItem?>
+<?import javafx.scene.control.SeparatorMenuItem?>
+
+<BorderPane xmlns:fx="http://javafx.com/fxml"
+            fx:controller="simpleex.ui.FxAppController"
+            prefHeight="750" prefWidth="1000">
+    <top>
+		<VBox>
+			<MenuBar >
+				<menus>
+					<fx:include fx:id="fileMenu" source="FileMenu.fxml"/>
+				</menus>
+			</MenuBar>
+		</VBox>
+    </top>
+    <left>
+		<VBox fillWidth="true">
+			<ListView fx:id="locationListView"/>
+			<Button text="Add location" onAction="#handleAddLocation"/>
+		</VBox>
+	</left>
+	<center>
+		<VBox>
+			<MapBase fx:id="mapView">
+				<MapTileLayer name="OpenStreetMap" minZoomLevel="0" maxZoomLevel="17">
+					<tileSource>
+						<TileSource urlFormat="http://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png"/>
+					<!--
+						<TileSource urlFormat="https://{c}.tile.openstreetmap.org/{z}/{x}/{y}.png"/>
+						<TileSource urlFormat="http://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=topo2&amp;zoom={z}&amp;x={x}&amp;y={y}"/>
+						<TileSource urlFormat="http://mt1.google.com/vt/lyrs=m@129&amp;hl=en&amp;s=Galileo&amp;z={z}&amp;x={x}&amp;y={y}"/>
+					-->
+					</tileSource>
+				</MapTileLayer>
+			</MapBase>
+			<Slider fx:id="zoomSlider" min="1" max="20" value="9"/>
+		</VBox>
+	</center>
+</BorderPane>