Merge pull request #1388 from umap-project/pictogram-category

Pictogram category
This commit is contained in:
Yohan Boniface 2023-11-07 18:01:15 +01:00 committed by GitHub
commit 3034ebc50b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 133 additions and 83 deletions

View file

@ -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.

View file

@ -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('-', ' ')

View 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),
),
]

View file

@ -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,
} }

View file

@ -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;

View file

@ -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
} }

View file

@ -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',