Thinking about the user journey, make tags visible at a certain point
This commit is contained in:
parent
47d755e59f
commit
cd37d8db98
14 changed files with 175 additions and 49 deletions
|
@ -15,6 +15,7 @@ import Locale from "../UI/i18n/Locale";
|
|||
import {State} from "../State";
|
||||
import {TagRenderingOptions} from "./TagRenderingOptions";
|
||||
import Translation from "../UI/i18n/Translation";
|
||||
import {SubtleButton} from "../UI/Base/SubtleButton";
|
||||
|
||||
|
||||
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 _saveButton: UIElement;
|
||||
private readonly _friendlyLogin: UIElement;
|
||||
|
||||
private readonly _skipButton: UIElement;
|
||||
private readonly _editButton: UIElement;
|
||||
|
||||
|
@ -142,14 +145,17 @@ export class TagRendering extends UIElement implements TagDependantUIElement {
|
|||
if (tags === undefined) {
|
||||
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 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 = () => {
|
||||
self._questionSkipped.setData(true);
|
||||
|
@ -161,6 +167,9 @@ export class TagRendering extends UIElement implements TagDependantUIElement {
|
|||
this._saveButton = new SaveButton(this._questionElement.GetValue())
|
||||
.onClick(save);
|
||||
|
||||
this._friendlyLogin = Translations.t.general.loginToStart
|
||||
.onClick(() => State.state.osmConnection.AttemptLogin())
|
||||
|
||||
this._editButton = new FixedUiElement("");
|
||||
if (this._question !== undefined) {
|
||||
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 {
|
||||
|
||||
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) {
|
||||
// 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 tags = this._source.map(tags => self._tagsPreprocessor(self._source.data));
|
||||
let transl : Translation;
|
||||
if (typeof (template) === "string") {
|
||||
transl = new Translation({en: TagUtils.ApplyTemplate(template, tags)});
|
||||
}else{
|
||||
transl = template;
|
||||
}
|
||||
|
||||
return new VariableUiElement(tags.map(tags => transl.Subs(tags).InnerRender()));
|
||||
return new VariableUiElement(tags.map(tags => {
|
||||
const tr = Translations.WT(template);
|
||||
if (tr.Subs === undefined) {
|
||||
// This is a weird edge case
|
||||
return tr.InnerRender();
|
||||
}
|
||||
return tr.Subs(tags).InnerRender()
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -64,8 +64,10 @@ export class InitUiElements {
|
|||
tabs.push({header: `<img src='${'./assets/share.svg'}'>`, content: new ShareScreen()});
|
||||
}
|
||||
|
||||
if (State.state.featureSwitchMoreQuests.data) {
|
||||
tabs.push({header: `<img src='${'./assets/add.svg'}'>`, content: new MoreScreen()});
|
||||
if (State.state.featureSwitchMoreQuests.data){
|
||||
|
||||
tabs.push({header: `<img src='${'./assets/add.svg'}'>`
|
||||
, content: new MoreScreen()});
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ export abstract class TagsFilter {
|
|||
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));
|
||||
}
|
||||
|
||||
asHumanString() {
|
||||
return this.key+"="+this.value;
|
||||
asHumanString(linkToWiki: boolean) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -200,9 +205,9 @@ export class Or extends TagsFilter {
|
|||
}
|
||||
return new Or(newChoices);
|
||||
}
|
||||
|
||||
asHumanString() {
|
||||
return this.or.map(t => t.asHumanString()).join("|");
|
||||
|
||||
asHumanString(linkToWiki: boolean) {
|
||||
return this.or.map(t => t.asHumanString(linkToWiki)).join("|");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -262,8 +267,8 @@ export class And extends TagsFilter {
|
|||
return new And(newChoices);
|
||||
}
|
||||
|
||||
asHumanString() {
|
||||
return this.and.map(t => t.asHumanString()).join("&");
|
||||
asHumanString(linkToWiki: boolean) {
|
||||
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));
|
||||
}
|
||||
|
||||
asHumanString() {
|
||||
return "!"+this.not.asHumanString();
|
||||
asHumanString(linkToWiki: boolean) {
|
||||
return "!" + this.not.asHumanString(linkToWiki);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
33
README.md
33
README.md
|
@ -9,7 +9,8 @@ The design goals of MapComplete are to be:
|
|||
|
||||
- Easy to use, both on web and on mobile
|
||||
- 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.
|
||||
|
||||
|
@ -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)
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
# Creating your own theme
|
||||
|
||||
You can create [your own theme too](https://pietervdvn.github.io/MapComplete/customGenerator.html)
|
||||
|
||||
|
||||
# Attributions:
|
||||
|
||||
Data from OpenStreetMap
|
||||
|
|
12
State.ts
12
State.ts
|
@ -24,7 +24,17 @@ export class State {
|
|||
// The singleton of the global 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;
|
||||
|
||||
|
|
|
@ -15,5 +15,5 @@ export class VariableUiElement extends UIElement {
|
|||
InnerRender(): string {
|
||||
return this._html.data;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -18,6 +18,7 @@ import {Tag} from "../../Logic/TagsFilter";
|
|||
import {DropDown} from "../Input/DropDown";
|
||||
import {TagRendering} from "../../Customizations/TagRendering";
|
||||
import {LayerDefinition} from "../../Customizations/LayerDefinition";
|
||||
import {State} from "../../State";
|
||||
|
||||
|
||||
TagRendering.injectFunction();
|
||||
|
@ -620,8 +621,8 @@ export class ThemeGenerator extends UIElement {
|
|||
if (!this.userDetails.data.loggedIn) {
|
||||
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) {
|
||||
return "You need at least 500 changesets to create your own theme.";
|
||||
if (this.userDetails.data.csCount < State.userJourney.themeGeneratorUnlock ) {
|
||||
return `You need at least ${State.userJourney.themeGeneratorUnlock} changesets to create your own theme.`;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -98,7 +98,7 @@ export class FeatureInfoBox extends UIElement {
|
|||
info.push(infobox);
|
||||
} else if (infobox.IsQuestioning()) {
|
||||
questions.push(infobox);
|
||||
} else if(infobox.IsSkipped()){
|
||||
} else if (infobox.IsSkipped()) {
|
||||
// This question is neither known nor questioning -> it was skipped
|
||||
skippedQuestions++;
|
||||
}
|
||||
|
@ -107,7 +107,19 @@ export class FeatureInfoBox extends UIElement {
|
|||
|
||||
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
|
||||
let mostImportantQuestion;
|
||||
let score = -1000;
|
||||
|
|
|
@ -2,11 +2,6 @@ import {UIElement} from "./UIElement";
|
|||
import {VerticalCombine} from "./Base/VerticalCombine";
|
||||
import Translations from "./i18n/Translations";
|
||||
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 {SubtleButton} from "./Base/SubtleButton";
|
||||
import {State} from "../State";
|
||||
|
@ -21,6 +16,7 @@ export class MoreScreen extends UIElement {
|
|||
}
|
||||
|
||||
InnerRender(): string {
|
||||
|
||||
const tr = Translations.t.general.morescreen;
|
||||
|
||||
const els: UIElement[] = []
|
||||
|
@ -36,7 +32,8 @@ export class MoreScreen extends UIElement {
|
|||
if (!State.state.osmConnection.userDetails.data.loggedIn) {
|
||||
continue;
|
||||
}
|
||||
if (State.state.osmConnection.userDetails.data.csCount < 50) {
|
||||
if (State.state.osmConnection.userDetails.data.csCount <
|
||||
State.userJourney.customLayoutUnlock) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import {State} from "../State";
|
|||
|
||||
import {UIEventSource} from "../Logic/UIEventSource";
|
||||
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
|
||||
|
@ -58,18 +59,26 @@ export class SimpleAddUI extends UIElement {
|
|||
} else {
|
||||
icon = preset.icon;
|
||||
}
|
||||
}else{
|
||||
console.warn("No icon defined for preset ", preset, "in layer ",layer.layerDef.id)
|
||||
} else {
|
||||
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(
|
||||
icon,
|
||||
new Combine([
|
||||
"<b>",
|
||||
preset.title,
|
||||
"</b><br/>",
|
||||
preset.description !== undefined ? preset.description : ""])
|
||||
"</b>",
|
||||
preset.description !== undefined ? new Combine(["<br/>", preset.description]) : "",
|
||||
tagInfo
|
||||
])
|
||||
).onClick(
|
||||
() => {
|
||||
self.confirmButton = new SubtleButton(icon,
|
||||
|
@ -87,10 +96,12 @@ export class SimpleAddUI extends UIElement {
|
|||
icon: icon
|
||||
});
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
this._addButtons.push(button);
|
||||
this._addButtons.push(button);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,15 +131,23 @@ export class SimpleAddUI extends UIElement {
|
|||
if (this._confirmPreset.data !== undefined) {
|
||||
|
||||
if(userDetails.data.dryRun){
|
||||
this.CreatePoint(this._confirmPreset.data.tags, this._confirmPreset.data.layerToAddTo)();
|
||||
return "";
|
||||
// this.CreatePoint(this._confirmPreset.data.tags, this._confirmPreset.data.layerToAddTo)();
|
||||
// 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([
|
||||
Translations.t.general.add.confirmIntro.Subs({title: this._confirmPreset.data.name}),
|
||||
userDetails.data.dryRun ? "<span class='alert'>TESTING - changes won't be saved</span>":"",
|
||||
this.confirmButton,
|
||||
this.cancelButton
|
||||
this.cancelButton,
|
||||
tagInfo
|
||||
|
||||
]).Render();
|
||||
|
||||
|
|
|
@ -140,7 +140,12 @@ export abstract class UIElement extends UIEventSource<string>{
|
|||
public IsEmpty(): boolean {
|
||||
return this.InnerRender() === "";
|
||||
}
|
||||
|
||||
|
||||
public SetClass(clss: string): UIElement {
|
||||
this.clss = clss;
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -673,6 +673,10 @@ export default class Translations {
|
|||
nl: "Je bent aangemeld. Welkom terug!",
|
||||
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: new Translation({
|
||||
en: "Search a location",
|
||||
|
|
|
@ -28,6 +28,11 @@
|
|||
"title": "Toilet",
|
||||
"tags": "amenity=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": [
|
||||
|
|
19
index.css
19
index.css
|
@ -1111,13 +1111,28 @@ form {
|
|||
background-color: #3a3aeb;
|
||||
color: white;
|
||||
padding: 0.2em;
|
||||
padding-left: 0.3em;
|
||||
padding-right: 0.3em;
|
||||
padding-left: 0.6em;
|
||||
padding-right: 0.6em;
|
||||
font-size: x-large;
|
||||
font-weight: bold;
|
||||
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 {
|
||||
display: inline-block;
|
||||
border: solid lightgrey 2px;
|
||||
|
|
Loading…
Reference in a new issue