Merge pull request #1388 from umap-project/pictogram-category
Pictogram category
This commit is contained in:
commit
3034ebc50b
7 changed files with 133 additions and 83 deletions
|
@ -10,11 +10,17 @@ Icons (aka pictograms in uMap sources) can be used in your map markers.
|
||||||
|
|
||||||
Icons are not embedded in uMap sources, you will have to add them manually. So you can choose which icons you want to use.
|
Icons are not embedded in uMap sources, you will have to add them manually. So you can choose which icons you want to use.
|
||||||
|
|
||||||
|
You can use either PNG, JPG or SVG files. SVG files are recommended.
|
||||||
|
|
||||||
|
When using SVG, it's recommended to use icons without color. uMap will switch to white colors
|
||||||
|
automatically according to the marker background color.
|
||||||
|
|
||||||
Example of icons libraries you may want to use:
|
Example of icons libraries you may want to use:
|
||||||
|
|
||||||
- [Maki Icons](https://labs.mapbox.com/maki-icons/) (icon set made for map designers)
|
- [Maki Icons](https://labs.mapbox.com/maki-icons/) (icon set made for map designers)
|
||||||
- [Osmic Icons](https://gitlab.com/gmgeo/osmic)
|
- [Osmic Icons](https://gitlab.com/gmgeo/osmic)
|
||||||
- [SJJB Icons](http://www.sjjb.co.uk/mapicons/contactsheet)
|
- [SJJB Icons](http://www.sjjb.co.uk/mapicons/contactsheet)
|
||||||
|
- [Remix](https://remixicon.com/)
|
||||||
|
|
||||||
### Import icons manually
|
### Import icons manually
|
||||||
|
|
||||||
|
@ -22,32 +28,15 @@ You can import icons manually by going to your uMap admin page: `https://your.se
|
||||||
|
|
||||||
### Import icons automatically
|
### Import icons automatically
|
||||||
|
|
||||||
To import icons on your uMap server, you will need to use command `umap import_pictograms`
|
To import icons on your uMap server, you will need to use the command `umap import_pictograms`.
|
||||||
|
|
||||||
Note, you can get help with `umap import_pictograms -h`
|
Note: you can get help with `umap import_pictograms -h`
|
||||||
|
|
||||||
In this example, we will import Maki icons.
|
Basic usage:
|
||||||
|
|
||||||
First, we download icons from main site. Inside the downloaded archive, we keep only the icons folder that contains svg files. Place this folder on your server.
|
umap import_pictograms --attribution "Maki Icons by Mapbox" path/to/icons/directory/
|
||||||
|
|
||||||
Go inside icons folder and remove tiny icons: `rm *-11.svg`
|
### Categories
|
||||||
|
|
||||||
Now, we will use imagemagick to convert svg to png.
|
uMap can render icons grouped into categories. When using the import script, any
|
||||||
|
subfolder will be used as category.
|
||||||
`for file in *.svg; do convert -background none $file ${file%15.svg}24.png; done`
|
|
||||||
|
|
||||||
To have white icons use:
|
|
||||||
`for file in *.svg; do convert -background none -fuzz 100% -fill white -opaque black $file ${file%15.svg}24.png; done`
|
|
||||||
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- you may also want to resize image with option `-resize 24x`
|
|
||||||
- this solution is not optimal, generated png are blurry.
|
|
||||||
|
|
||||||
This will convert the svg to png and rename them from `*-15.svg` to `*-24.png`
|
|
||||||
|
|
||||||
Now we will import icons. Note: icons names must end with `-24.png`
|
|
||||||
|
|
||||||
`umap import_pictograms --attribution "Maki Icons by Mapbox" icons`
|
|
||||||
|
|
||||||
Done. Icons are imported.
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import os
|
from pathlib import Path
|
||||||
|
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
@ -7,40 +7,61 @@ from umap.models import Pictogram
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Import pictograms from a folder'
|
help = "Import pictograms from a folder"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('path')
|
parser.add_argument("path")
|
||||||
parser.add_argument('--attribution', required=True,
|
parser.add_argument(
|
||||||
help='Attribution of the imported pictograms')
|
"--attribution",
|
||||||
parser.add_argument('--suffix',
|
required=True,
|
||||||
help='Optional suffix to add to each name')
|
help="Attribution of the imported pictograms",
|
||||||
parser.add_argument('--force', action='store_true',
|
)
|
||||||
help='Update picto if it already exists.')
|
parser.add_argument(
|
||||||
|
"--extensions",
|
||||||
|
help="Optional list of extensins to process",
|
||||||
|
nargs="+",
|
||||||
|
default=[".svg"],
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--exclude",
|
||||||
|
help="Optional list of files or dirs to exclude",
|
||||||
|
nargs="+",
|
||||||
|
default=["font"],
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force", action="store_true", help="Update picto if it already exists."
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
path = options['path']
|
self.path = Path(options["path"])
|
||||||
attribution = options['attribution']
|
self.attribution = options["attribution"]
|
||||||
suffix = options['suffix']
|
self.extensions = options["extensions"]
|
||||||
force = options['force']
|
self.force = options["force"]
|
||||||
for filename in os.listdir(path):
|
self.exclude = options["exclude"]
|
||||||
if filename.endswith("-24.png"):
|
self.handle_directory(self.path)
|
||||||
name = self.extract_name(filename)
|
|
||||||
if suffix:
|
def handle_directory(self, path):
|
||||||
name = '{name}{suffix}'.format(name=name, suffix=suffix)
|
for filename in path.iterdir():
|
||||||
|
if filename.name in self.exclude:
|
||||||
|
continue
|
||||||
|
if filename.is_dir():
|
||||||
|
self.handle_directory(filename)
|
||||||
|
continue
|
||||||
|
if filename.suffix in self.extensions:
|
||||||
|
name = filename.stem
|
||||||
picto = Pictogram.objects.filter(name=name).last()
|
picto = Pictogram.objects.filter(name=name).last()
|
||||||
if picto:
|
if picto:
|
||||||
if not force:
|
if not self.force:
|
||||||
self.stdout.write(u"⚠ Pictogram with name '{name}' already exists. Skipping.".format(name=name)) # noqa
|
self.stdout.write(
|
||||||
|
f"⚠ Pictogram with name '{name}' already exists. Skipping."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
picto = Pictogram()
|
picto = Pictogram()
|
||||||
picto.name = name
|
picto.name = name
|
||||||
filepath = os.path.join(path, filename)
|
if (path.name != self.path.name): # Subfolders only
|
||||||
with open(filepath, 'rb') as f:
|
picto.category = path.name
|
||||||
picto.attribution = attribution
|
picto.attribution = self.attribution
|
||||||
picto.pictogram.save(filename, File(f), save=True)
|
with (path / filename).open("rb") as f:
|
||||||
self.stdout.write(u"✔ Imported pictogram {filename}.".format(filename=filename)) # noqa
|
picto.pictogram.save(filename.name, File(f), save=True)
|
||||||
|
self.stdout.write(f"✔ Imported pictogram {filename}.")
|
||||||
def extract_name(self, filename):
|
|
||||||
return filename[:-7].replace('-', ' ')
|
|
||||||
|
|
17
umap/migrations/0016_pictogram_category.py
Normal file
17
umap/migrations/0016_pictogram_category.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 4.2.2 on 2023-10-30 17:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("umap", "0015_alter_pictogram_pictogram"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="pictogram",
|
||||||
|
name="category",
|
||||||
|
field=models.CharField(blank=True, max_length=300, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -284,6 +284,7 @@ class Pictogram(NamedModel):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
attribution = models.CharField(max_length=300)
|
attribution = models.CharField(max_length=300)
|
||||||
|
category = models.CharField(max_length=300, null=True, blank=True)
|
||||||
pictogram = models.FileField(upload_to="pictogram")
|
pictogram = models.FileField(upload_to="pictogram")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -292,6 +293,7 @@ class Pictogram(NamedModel):
|
||||||
"id": self.pk,
|
"id": self.pk,
|
||||||
"attribution": self.attribution,
|
"attribution": self.attribution,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
|
"category": self.category,
|
||||||
"src": self.pictogram.url,
|
"src": self.pictogram.url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -521,16 +521,14 @@ i.info {
|
||||||
margin-top: -8px;
|
margin-top: -8px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
}
|
}
|
||||||
.umap-icon-list, .umap-pictogram-list {
|
.umap-pictogram-grid {
|
||||||
clear: both;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.umap-icon-choice {
|
.umap-pictogram-choice {
|
||||||
display: block;
|
|
||||||
float: left;
|
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-image: url('./img/icon-bg.png');
|
background-image: url('./img/icon-bg.png');
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -538,16 +536,16 @@ i.info {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
.umap-icon-choice img {
|
.umap-pictogram-choice img {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
max-width: 24px;
|
max-width: 24px;
|
||||||
}
|
}
|
||||||
.umap-icon-choice:hover,
|
.umap-pictogram-choice:hover,
|
||||||
.umap-icon-choice.selected,
|
.umap-pictogram-choice.selected,
|
||||||
.umap-color-picker span:hover {
|
.umap-color-picker span:hover {
|
||||||
box-shadow: 0 0 4px 0 black;
|
box-shadow: 0 0 4px 0 black;
|
||||||
}
|
}
|
||||||
.umap-icon-choice .leaflet-marker-icon {
|
.umap-pictogram-choice .leaflet-marker-icon {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 30px;
|
left: 30px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -201,6 +201,16 @@ L.Util.greedyTemplate = (str, data, ignore) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
L.Util.naturalSort = (a, b) => {
|
||||||
|
return a
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.localeCompare(b.toString().toLowerCase(), L.lang || 'en', {
|
||||||
|
sensitivity: 'base',
|
||||||
|
numeric: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
L.Util.sortFeatures = (features, sortKey) => {
|
L.Util.sortFeatures = (features, sortKey) => {
|
||||||
const sortKeys = (sortKey || 'name').split(',')
|
const sortKeys = (sortKey || 'name').split(',')
|
||||||
|
|
||||||
|
@ -214,19 +224,9 @@ L.Util.sortFeatures = (features, sortKey) => {
|
||||||
let score
|
let score
|
||||||
const valA = a.properties[sortKey] || ''
|
const valA = a.properties[sortKey] || ''
|
||||||
const valB = b.properties[sortKey] || ''
|
const valB = b.properties[sortKey] || ''
|
||||||
if (!valA) {
|
if (!valA) score = -1
|
||||||
score = -1
|
else if (!valB) score = 1
|
||||||
} else if (!valB) {
|
else score = L.Util.naturalSort(valA, valB)
|
||||||
score = 1
|
|
||||||
} else {
|
|
||||||
score = valA
|
|
||||||
.toString()
|
|
||||||
.toLowerCase()
|
|
||||||
.localeCompare(valB.toString().toLowerCase(), L.lang || 'en', {
|
|
||||||
sensitivity: 'base',
|
|
||||||
numeric: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (score === 0 && sortKeys[i + 1]) return sort(a, b, i + 1)
|
if (score === 0 && sortKeys[i + 1]) return sort(a, b, i + 1)
|
||||||
return score * reverse
|
return score * reverse
|
||||||
}
|
}
|
||||||
|
|
|
@ -547,7 +547,7 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({
|
||||||
const img = L.DomUtil.create(
|
const img = L.DomUtil.create(
|
||||||
'img',
|
'img',
|
||||||
'',
|
'',
|
||||||
L.DomUtil.create('div', 'umap-icon-choice', this.buttonsContainer)
|
L.DomUtil.create('div', 'umap-pictogram-choice', this.buttonsContainer)
|
||||||
)
|
)
|
||||||
img.src = this.value()
|
img.src = this.value()
|
||||||
L.DomEvent.on(img, 'click', this.fetchIconList, this)
|
L.DomEvent.on(img, 'click', this.fetchIconList, this)
|
||||||
|
@ -555,7 +555,7 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({
|
||||||
const el = L.DomUtil.create(
|
const el = L.DomUtil.create(
|
||||||
'span',
|
'span',
|
||||||
'',
|
'',
|
||||||
L.DomUtil.create('div', 'umap-icon-choice', this.buttonsContainer)
|
L.DomUtil.create('div', 'umap-pictogram-choice', this.buttonsContainer)
|
||||||
)
|
)
|
||||||
el.textContent = this.value()
|
el.textContent = this.value()
|
||||||
L.DomEvent.on(el, 'click', this.fetchIconList, this)
|
L.DomEvent.on(el, 'click', this.fetchIconList, this)
|
||||||
|
@ -570,11 +570,11 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
addIconPreview: function (pictogram) {
|
addIconPreview: function (pictogram, parent) {
|
||||||
const baseClass = 'umap-icon-choice',
|
const baseClass = 'umap-pictogram-choice',
|
||||||
value = pictogram.src,
|
value = pictogram.src,
|
||||||
className = value === this.value() ? `${baseClass} selected` : baseClass,
|
className = value === this.value() ? `${baseClass} selected` : baseClass,
|
||||||
container = L.DomUtil.create('div', className, this.pictogramsContainer),
|
container = L.DomUtil.create('div', className, parent),
|
||||||
img = L.DomUtil.create('img', '', container)
|
img = L.DomUtil.create('img', '', container)
|
||||||
img.src = value
|
img.src = value
|
||||||
if (pictogram.name && pictogram.attribution) {
|
if (pictogram.name && pictogram.attribution) {
|
||||||
|
@ -602,7 +602,7 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
search: function (e) {
|
search: function (e) {
|
||||||
const icons = [...this.parentNode.querySelectorAll('.umap-icon-choice')],
|
const icons = [...this.parentNode.querySelectorAll('.umap-pictogram-choice')],
|
||||||
search = this.searchInput.value.toLowerCase()
|
search = this.searchInput.value.toLowerCase()
|
||||||
icons.forEach((el) => {
|
icons.forEach((el) => {
|
||||||
if (el.title.toLowerCase().indexOf(search) != -1) el.style.display = 'block'
|
if (el.title.toLowerCase().indexOf(search) != -1) el.style.display = 'block'
|
||||||
|
@ -610,13 +610,36 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addCategory: function (category, items) {
|
||||||
|
const parent = L.DomUtil.create(
|
||||||
|
'div',
|
||||||
|
'umap-pictogram-category',
|
||||||
|
this.pictogramsContainer
|
||||||
|
),
|
||||||
|
title = L.DomUtil.add('h6', '', parent, category),
|
||||||
|
grid = L.DomUtil.create('div', 'umap-pictogram-grid', parent)
|
||||||
|
for (let item of items) {
|
||||||
|
this.addIconPreview(item, grid)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
buildIconList: function (data) {
|
buildIconList: function (data) {
|
||||||
this.searchInput = L.DomUtil.create('input', '', this.pictogramsContainer)
|
this.searchInput = L.DomUtil.create('input', '', this.pictogramsContainer)
|
||||||
this.searchInput.type = 'search'
|
this.searchInput.type = 'search'
|
||||||
this.searchInput.placeholder = L._('Search')
|
this.searchInput.placeholder = L._('Search')
|
||||||
L.DomEvent.on(this.searchInput, 'input', this.search, this)
|
L.DomEvent.on(this.searchInput, 'input', this.search, this)
|
||||||
for (const idx in data.pictogram_list) {
|
const categories = {}
|
||||||
this.addIconPreview(data.pictogram_list[idx])
|
let category
|
||||||
|
for (const props of data.pictogram_list) {
|
||||||
|
category = props.category || L._('Generic')
|
||||||
|
categories[category] = categories[category] || []
|
||||||
|
categories[category].push(props)
|
||||||
|
}
|
||||||
|
const sorted = Object.entries(categories).toSorted(([a], [b]) =>
|
||||||
|
L.Util.naturalSort(a, b)
|
||||||
|
)
|
||||||
|
for (let [category, items] of sorted) {
|
||||||
|
this.addCategory(category, items)
|
||||||
}
|
}
|
||||||
const closeButton = L.DomUtil.createButton(
|
const closeButton = L.DomUtil.createButton(
|
||||||
'button action-button',
|
'button action-button',
|
||||||
|
|
Loading…
Reference in a new issue