Thinking about the user journey, make tags visible at a certain point

This commit is contained in:
Pieter Vander Vennet 2020-08-22 18:57:27 +02:00
parent 47d755e59f
commit cd37d8db98
14 changed files with 175 additions and 49 deletions

View file

@ -15,6 +15,7 @@ import Locale from "../UI/i18n/Locale";
import {State} from "../State"; import {State} from "../State";
import {TagRenderingOptions} from "./TagRenderingOptions"; import {TagRenderingOptions} from "./TagRenderingOptions";
import Translation from "../UI/i18n/Translation"; import Translation from "../UI/i18n/Translation";
import {SubtleButton} from "../UI/Base/SubtleButton";
export class TagRendering extends UIElement implements TagDependantUIElement { export class TagRendering extends UIElement implements TagDependantUIElement {
@ -39,6 +40,8 @@ export class TagRendering extends UIElement implements TagDependantUIElement {
private readonly _questionElement: InputElement<TagsFilter>; private readonly _questionElement: InputElement<TagsFilter>;
private readonly _saveButton: UIElement; private readonly _saveButton: UIElement;
private readonly _friendlyLogin: UIElement;
private readonly _skipButton: UIElement; private readonly _skipButton: UIElement;
private readonly _editButton: UIElement; private readonly _editButton: UIElement;
@ -142,14 +145,17 @@ export class TagRendering extends UIElement implements TagDependantUIElement {
if (tags === undefined) { if (tags === undefined) {
return ""; return "";
} }
if ((State.state?.osmConnection?.userDetails?.data?.csCount ?? 0) < 200) { const csCount = State.state.osmConnection.userDetails.data.csCount;
if (csCount < State.userJourney.tagsVisibleAt) {
return ""; return "";
} }
return tags.asHumanString() if (csCount < State.userJourney.tagsVisibleAndWikiLinked) {
return new FixedUiElement(tags.asHumanString(false)).SetClass("subtle").Render();
}
return tags.asHumanString(true);
} }
) )
); );
this._appliedTags.clss = "subtle";
const cancel = () => { const cancel = () => {
self._questionSkipped.setData(true); self._questionSkipped.setData(true);
@ -161,6 +167,9 @@ export class TagRendering extends UIElement implements TagDependantUIElement {
this._saveButton = new SaveButton(this._questionElement.GetValue()) this._saveButton = new SaveButton(this._questionElement.GetValue())
.onClick(save); .onClick(save);
this._friendlyLogin = Translations.t.general.loginToStart
.onClick(() => State.state.osmConnection.AttemptLogin())
this._editButton = new FixedUiElement(""); this._editButton = new FixedUiElement("");
if (this._question !== undefined) { if (this._question !== undefined) {
this._editButton = new FixedUiElement("<img class='editbutton' src='./assets/pencil.svg' alt='edit'>") this._editButton = new FixedUiElement("<img class='editbutton' src='./assets/pencil.svg' alt='edit'>")
@ -381,6 +390,17 @@ export class TagRendering extends UIElement implements TagDependantUIElement {
InnerRender(): string { InnerRender(): string {
if (this.IsQuestioning() && !State.state.osmConnection.userDetails.data.loggedIn) {
const question =
this.ApplyTemplate(this._question).Render();
return "<div class='question'>" +
"<span class='question-text'>" + question + "</span>" +
"<br/>" +
"<span class='login-button-friendly'>" + this._friendlyLogin.Render() + "</span>" +
"</div>"
}
if (this.IsQuestioning() || this._editMode.data) { if (this.IsQuestioning() || this._editMode.data) {
// Not yet known or questioning, we have to ask a question // Not yet known or questioning, we have to ask a question
@ -430,14 +450,14 @@ export class TagRendering extends UIElement implements TagDependantUIElement {
} }
const self = this; const self = this;
const tags = this._source.map(tags => self._tagsPreprocessor(self._source.data)); const tags = this._source.map(tags => self._tagsPreprocessor(self._source.data));
let transl : Translation; return new VariableUiElement(tags.map(tags => {
if (typeof (template) === "string") { const tr = Translations.WT(template);
transl = new Translation({en: TagUtils.ApplyTemplate(template, tags)}); if (tr.Subs === undefined) {
}else{ // This is a weird edge case
transl = template; return tr.InnerRender();
} }
return tr.Subs(tags).InnerRender()
return new VariableUiElement(tags.map(tags => transl.Subs(tags).InnerRender())); }));
} }

View file

@ -64,8 +64,10 @@ export class InitUiElements {
tabs.push({header: `<img src='${'./assets/share.svg'}'>`, content: new ShareScreen()}); tabs.push({header: `<img src='${'./assets/share.svg'}'>`, content: new ShareScreen()});
} }
if (State.state.featureSwitchMoreQuests.data) { if (State.state.featureSwitchMoreQuests.data){
tabs.push({header: `<img src='${'./assets/add.svg'}'>`, content: new MoreScreen()});
tabs.push({header: `<img src='${'./assets/add.svg'}'>`
, content: new MoreScreen()});
} }

View file

@ -8,7 +8,7 @@ export abstract class TagsFilter {
return this.matches(TagUtils.proprtiesToKV(properties)); return this.matches(TagUtils.proprtiesToKV(properties));
} }
abstract asHumanString(); abstract asHumanString(linkToWiki: boolean);
} }
@ -150,8 +150,13 @@ export class Tag extends TagsFilter {
return new Tag(this.key, TagUtils.ApplyTemplate(this.value as string, tags)); return new Tag(this.key, TagUtils.ApplyTemplate(this.value as string, tags));
} }
asHumanString() { asHumanString(linkToWiki: boolean) {
return this.key+"="+this.value; if (linkToWiki) {
return `<a href='https://wiki.openstreetmap.org/wiki/Key:${this.key}' target='_blank'>${this.key}</a>` +
`=` +
`<a href='https://wiki.openstreetmap.org/wiki/Tag:${this.key}%3D${this.value}' target='_blank'>${this.value}</a>`
}
return this.key + "=" + this.value;
} }
} }
@ -201,8 +206,8 @@ export class Or extends TagsFilter {
return new Or(newChoices); return new Or(newChoices);
} }
asHumanString() { asHumanString(linkToWiki: boolean) {
return this.or.map(t => t.asHumanString()).join("|"); return this.or.map(t => t.asHumanString(linkToWiki)).join("|");
} }
} }
@ -262,8 +267,8 @@ export class And extends TagsFilter {
return new And(newChoices); return new And(newChoices);
} }
asHumanString() { asHumanString(linkToWiki: boolean) {
return this.and.map(t => t.asHumanString()).join("&"); return this.and.map(t => t.asHumanString(linkToWiki)).join("&");
} }
} }
@ -288,8 +293,8 @@ export class Not extends TagsFilter{
return new Not(this.not.substituteValues(tags)); return new Not(this.not.substituteValues(tags));
} }
asHumanString() { asHumanString(linkToWiki: boolean) {
return "!"+this.not.asHumanString(); return "!" + this.not.asHumanString(linkToWiki);
} }
} }

View file

@ -9,7 +9,8 @@ The design goals of MapComplete are to be:
- Easy to use, both on web and on mobile - Easy to use, both on web and on mobile
- Easy to deploy (by not having a backand) - Easy to deploy (by not having a backand)
- Easy to modify - Easy to set up a custom theme
- Easy to fall down the rabbit hole of OSM
The basic functionality is to download some map features from Overpass and then ask certain questions. An answer is sent back to directly to OpenStreetMap. The basic functionality is to download some map features from Overpass and then ask certain questions. An answer is sent back to directly to OpenStreetMap.
@ -26,6 +27,31 @@ An explicit non-goal of MapComplete is to modify geometries of ways. Although ad
Have a theme idea? Drop it in the [issues](https://github.com/pietervdvn/MapComplete/issues) Have a theme idea? Drop it in the [issues](https://github.com/pietervdvn/MapComplete/issues)
## User journey
MapComplete is set up to lure people into OpenStreetMap and to teach them while they are on the go, step by step.
A typical user journey would be:
0) Oh, this is a cool map of _my specific interest_! There is a lot of data already...
0a) The user might discover the explanation about OSM in the dedicated tab page
0b) The user might discover the other themes in the other tab
0c) The user might share the map and/or embed it
1) The user clicks that big tempting button 'login' in order to answer questions. The user makes an account - a big step.
2) The user answers a question! Hooray!
When at least one question is answered (aka: having one changeset on OSM), adding a new point is unlocked
3) The user adds a new POI somewhere
3a) Note that _all messages_ must be read before being able to add a point. In other words, sending a message to a misbehaving MapComplete user acts as having a zero-minutes-block. This is added deliberately to avoid new users fucking up too much
4) At 50 changesets, the custom layout becomes available
5) At 200 changesets, the tags become visible when answering questions and when adding a new point from a preset. This is to give more control to power users and to teach new users the tagging scheme
5) At 250 changesets, the tags get linked to the wiki
6) At 500 changesets, I expect users to be power users and to be comfortable with tagging scheme and such. The custom theme generator is unlocked.
## License ## License
GPL + pingback. GPL + pingback.
@ -88,6 +114,11 @@ TODO: erase cookies of third party websites and API's
Help to translate mapcomplete. Fork this project, open [the file containing all translations](https://github.com/pietervdvn/MapComplete/blob/master/UI/i18n/Translations.ts), add your language and send a pull request. Help to translate mapcomplete. Fork this project, open [the file containing all translations](https://github.com/pietervdvn/MapComplete/blob/master/UI/i18n/Translations.ts), add your language and send a pull request.
# Creating your own theme
You can create [your own theme too](https://pietervdvn.github.io/MapComplete/customGenerator.html)
# Attributions: # Attributions:
Data from OpenStreetMap Data from OpenStreetMap

View file

@ -24,7 +24,17 @@ export class State {
// The singleton of the global state // The singleton of the global state
public static state: State; public static state: State;
public static vNumber = "0.0.5d"; public static vNumber = "0.0.5e";
// The user journey states thresholds when a new feature gets unlocked
public static userJourney = {
customLayoutUnlock: 50,
themeGeneratorUnlock: 500,
tagsVisibleAt: 200,
tagsVisibleAndWikiLinked: 250
};
public static runningFromConsole: boolean = false; public static runningFromConsole: boolean = false;

View file

@ -18,6 +18,7 @@ import {Tag} from "../../Logic/TagsFilter";
import {DropDown} from "../Input/DropDown"; import {DropDown} from "../Input/DropDown";
import {TagRendering} from "../../Customizations/TagRendering"; import {TagRendering} from "../../Customizations/TagRendering";
import {LayerDefinition} from "../../Customizations/LayerDefinition"; import {LayerDefinition} from "../../Customizations/LayerDefinition";
import {State} from "../../State";
TagRendering.injectFunction(); TagRendering.injectFunction();
@ -620,8 +621,8 @@ export class ThemeGenerator extends UIElement {
if (!this.userDetails.data.loggedIn) { if (!this.userDetails.data.loggedIn) {
return new Combine(["Not logged in. You need to be logged in to create a theme.", this.loginButton]).Render(); return new Combine(["Not logged in. You need to be logged in to create a theme.", this.loginButton]).Render();
} }
if (this.userDetails.data.csCount < 500) { if (this.userDetails.data.csCount < State.userJourney.themeGeneratorUnlock ) {
return "You need at least 500 changesets to create your own theme."; return `You need at least ${State.userJourney.themeGeneratorUnlock} changesets to create your own theme.`;
} }

View file

@ -98,7 +98,7 @@ export class FeatureInfoBox extends UIElement {
info.push(infobox); info.push(infobox);
} else if (infobox.IsQuestioning()) { } else if (infobox.IsQuestioning()) {
questions.push(infobox); questions.push(infobox);
} else if(infobox.IsSkipped()){ } else if (infobox.IsSkipped()) {
// This question is neither known nor questioning -> it was skipped // This question is neither known nor questioning -> it was skipped
skippedQuestions++; skippedQuestions++;
} }
@ -107,7 +107,19 @@ export class FeatureInfoBox extends UIElement {
let questionsHtml = ""; let questionsHtml = "";
if (State.state.osmConnection.userDetails.data.loggedIn && questions.length > 0) { if (!State.state.osmConnection.userDetails.data.loggedIn) {
let mostImportantQuestion;
let score = -1000;
for (const question of questions) {
if (mostImportantQuestion === undefined || question.Priority() > score) {
mostImportantQuestion = question;
score = question.Priority();
}
}
questionsHtml = mostImportantQuestion.Render();
} else if (questions.length > 0) {
// We select the most important question and render that one // We select the most important question and render that one
let mostImportantQuestion; let mostImportantQuestion;
let score = -1000; let score = -1000;

View file

@ -2,11 +2,6 @@ import {UIElement} from "./UIElement";
import {VerticalCombine} from "./Base/VerticalCombine"; import {VerticalCombine} from "./Base/VerticalCombine";
import Translations from "./i18n/Translations"; import Translations from "./i18n/Translations";
import {AllKnownLayouts} from "../Customizations/AllKnownLayouts"; import {AllKnownLayouts} from "../Customizations/AllKnownLayouts";
import {FixedUiElement} from "./Base/FixedUiElement";
import {Utils} from "../Utils";
import {link} from "fs";
import {UIEventSource} from "./UIEventSource";
import {VariableUiElement} from "./Base/VariableUIElement";
import Combine from "./Base/Combine"; import Combine from "./Base/Combine";
import {SubtleButton} from "./Base/SubtleButton"; import {SubtleButton} from "./Base/SubtleButton";
import {State} from "../State"; import {State} from "../State";
@ -21,6 +16,7 @@ export class MoreScreen extends UIElement {
} }
InnerRender(): string { InnerRender(): string {
const tr = Translations.t.general.morescreen; const tr = Translations.t.general.morescreen;
const els: UIElement[] = [] const els: UIElement[] = []
@ -36,7 +32,8 @@ export class MoreScreen extends UIElement {
if (!State.state.osmConnection.userDetails.data.loggedIn) { if (!State.state.osmConnection.userDetails.data.loggedIn) {
continue; continue;
} }
if (State.state.osmConnection.userDetails.data.csCount < 50) { if (State.state.osmConnection.userDetails.data.csCount <
State.userJourney.customLayoutUnlock) {
continue; continue;
} }
} }

View file

@ -9,6 +9,7 @@ import {State} from "../State";
import {UIEventSource} from "../Logic/UIEventSource"; import {UIEventSource} from "../Logic/UIEventSource";
import {UserDetails} from "../Logic/Osm/OsmConnection"; import {UserDetails} from "../Logic/Osm/OsmConnection";
import {FixedUiElement} from "./Base/FixedUiElement";
/** /**
* Asks to add a feature at the last clicked location, at least if zoom is sufficient * Asks to add a feature at the last clicked location, at least if zoom is sufficient
@ -58,18 +59,26 @@ export class SimpleAddUI extends UIElement {
} else { } else {
icon = preset.icon; icon = preset.icon;
} }
}else{ } else {
console.warn("No icon defined for preset ", preset, "in layer ",layer.layerDef.id) console.warn("No icon defined for preset ", preset, "in layer ", layer.layerDef.id)
} }
const button = const csCount = State.state.osmConnection.userDetails.data.csCount;
let tagInfo = "";
if (csCount > State.userJourney.tagsVisibleAt) {
tagInfo = preset.tags.map(t => t.asHumanString(false)).join("&");
tagInfo = `<br/><span class='subtle'>${tagInfo}</span>`
}
const button: UIElement =
new SubtleButton( new SubtleButton(
icon, icon,
new Combine([ new Combine([
"<b>", "<b>",
preset.title, preset.title,
"</b><br/>", "</b>",
preset.description !== undefined ? preset.description : ""]) preset.description !== undefined ? new Combine(["<br/>", preset.description]) : "",
tagInfo
])
).onClick( ).onClick(
() => { () => {
self.confirmButton = new SubtleButton(icon, self.confirmButton = new SubtleButton(icon,
@ -90,6 +99,8 @@ export class SimpleAddUI extends UIElement {
) )
this._addButtons.push(button); this._addButtons.push(button);
} }
} }
@ -120,15 +131,23 @@ export class SimpleAddUI extends UIElement {
if (this._confirmPreset.data !== undefined) { if (this._confirmPreset.data !== undefined) {
if(userDetails.data.dryRun){ if(userDetails.data.dryRun){
this.CreatePoint(this._confirmPreset.data.tags, this._confirmPreset.data.layerToAddTo)(); // this.CreatePoint(this._confirmPreset.data.tags, this._confirmPreset.data.layerToAddTo)();
return ""; // return "";
}
let tagInfo = "";
const csCount = State.state.osmConnection.userDetails.data.csCount;
if (csCount > State.userJourney.tagsVisibleAt) {
tagInfo = this._confirmPreset.data .tags.map(t => t.asHumanString(csCount > State.userJourney.tagsVisibleAndWikiLinked)).join("&");
tagInfo = `<br/>More information about the preset: ${tagInfo}`
} }
return new Combine([ return new Combine([
Translations.t.general.add.confirmIntro.Subs({title: this._confirmPreset.data.name}), Translations.t.general.add.confirmIntro.Subs({title: this._confirmPreset.data.name}),
userDetails.data.dryRun ? "<span class='alert'>TESTING - changes won't be saved</span>":"", userDetails.data.dryRun ? "<span class='alert'>TESTING - changes won't be saved</span>":"",
this.confirmButton, this.confirmButton,
this.cancelButton this.cancelButton,
tagInfo
]).Render(); ]).Render();

View file

@ -141,6 +141,11 @@ export abstract class UIElement extends UIEventSource<string>{
return this.InnerRender() === ""; return this.InnerRender() === "";
} }
public SetClass(clss: string): UIElement {
this.clss = clss;
return this;
}
} }

View file

@ -673,6 +673,10 @@ export default class Translations {
nl: "Je bent aangemeld. Welkom terug!", nl: "Je bent aangemeld. Welkom terug!",
fr: "Vous êtes connecté, bienvenue" fr: "Vous êtes connecté, bienvenue"
}), }),
loginToStart: new T({
en: "Login to answer this question",
nl: "Meld je aan om deze vraag te beantwoorden"
}),
search: { search: {
search: new Translation({ search: new Translation({
en: "Search a location", en: "Search a location",

View file

@ -28,6 +28,11 @@
"title": "Toilet", "title": "Toilet",
"tags": "amenity=toilets", "tags": "amenity=toilets",
"description": "Only add public toilets" "description": "Only add public toilets"
},
{
"title": "Toilets with wheelchair accessible toilet",
"tags": "amenity=toilets&wheelchair=yes",
"description": "A restroom which has at least one wheelchair-accessible toilet"
} }
], ],
"tagRenderings": [ "tagRenderings": [

View file

@ -1111,13 +1111,28 @@ form {
background-color: #3a3aeb; background-color: #3a3aeb;
color: white; color: white;
padding: 0.2em; padding: 0.2em;
padding-left: 0.3em; padding-left: 0.6em;
padding-right: 0.3em; padding-right: 0.6em;
font-size: x-large; font-size: x-large;
font-weight: bold; font-weight: bold;
border-radius: 1.5em; border-radius: 1.5em;
} }
.login-button-friendly {
display: inline-block;
border: solid white 2px;
background-color: #3a3aeb;
color: white;
padding: 0.2em;
padding-left: 0.6em;
padding-right: 0.6em;
font-size: large;
font-weight: bold;
border-radius: 1.5em;
box-sizing: border-box;
width: 100%;
}
.save-non-active { .save-non-active {
display: inline-block; display: inline-block;
border: solid lightgrey 2px; border: solid lightgrey 2px;