fix pages zones

This commit is contained in:
Tykayn 2025-07-03 10:28:49 +02:00 committed by tykayn
parent 1c24ae1fea
commit 00977d4fba
9 changed files with 6085 additions and 433 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified"
xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="framework" type="frameworkType"/>
<xs:complexType name="commandType">
<xs:all>
<xs:element type="xs:string" name="name" minOccurs="1" maxOccurs="1"/>
<xs:element type="xs:string" name="params" minOccurs="0" maxOccurs="1"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
<xs:element type="optionsBeforeType" name="optionsBefore" minOccurs="0" maxOccurs="1"/>
</xs:all>
</xs:complexType>
<xs:complexType name="frameworkType">
<xs:sequence>
<xs:element type="xs:string" name="extraData" minOccurs="0" maxOccurs="1"/>
<xs:element type="commandType" name="command" maxOccurs="unbounded" minOccurs="0"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="invoke" use="required"/>
<xs:attribute type="xs:string" name="alias" use="required"/>
<xs:attribute type="xs:boolean" name="enabled" use="required"/>
<xs:attribute type="xs:integer" name="version" use="required"/>
<xs:attribute type="xs:string" name="frameworkId" use="optional"/>
</xs:complexType>
<xs:complexType name="optionsBeforeType">
<xs:sequence>
<xs:element type="optionType" name="option" maxOccurs="unbounded" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="optionType">
<xs:sequence>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="shortcut" use="optional"/>
<xs:attribute name="pattern" use="optional">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="space"/>
<xs:enumeration value="equals"/>
<xs:enumeration value="unknown"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:complexType>
</xs:schema>

View file

@ -2,9 +2,152 @@
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="App\" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="App\Tests\" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" /> <excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" /> <excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" /> <excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/vendor/composer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/collections" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/dbal" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/deprecations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/event-manager" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/inflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/instantiator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/lexer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/migrations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/egulias/email-validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/fakerphp/faker" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/guzzle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/promises" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/psr7" />
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nyholm/psr7" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/php-http/discovery" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-text-template" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-timer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/phpunit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-factory" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-message" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/comparator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/complexity" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/diff" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/environment" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/global-state" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/lines-of-code" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-enumerator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-reflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/recursion-context" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/resource-operations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/type" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/asset" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/browser-kit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/config" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/console" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/css-selector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/debug-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-messenger" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/form" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/framework-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/messenger" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/notifier" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/phpunit-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-icu" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-idn" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-mbstring" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php83" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php84" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/process" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-access" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/routing" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/runtime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-core" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-csrf" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/webpack-encore-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

169
.idea/php.xml generated Normal file
View file

@ -0,0 +1,169 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/php-http/discovery" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/nyholm/psr7" />
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/guzzle" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/promises" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit" />
<path value="$PROJECT_DIR$/vendor/sebastian/resource-operations" />
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/psr/http-client" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/link" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php83" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/webpack-encore-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-messenger" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/debug-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/notifier" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/form" />
<path value="$PROJECT_DIR$/vendor/symfony/messenger" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/fakerphp/faker" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PhpUnit">
<phpunit_settings>
<PhpUnitSettings configuration_file_path="$PROJECT_DIR$/phpunit.xml.dist" custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" use_configuration_file="true" />
</phpunit_settings>
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

10
.idea/phpunit.xml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PHPUnit">
<option name="directories">
<list>
<option value="$PROJECT_DIR$/tests" />
</list>
</option>
</component>
</project>

2
package-lock.json generated
View file

@ -1,5 +1,5 @@
{ {
"name": "osm-commerces", "name": "osm-commerce-sf",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View file

@ -115,14 +115,14 @@ function getDelta(data, days) {
return ref !== null ? last - ref : null; return ref !== null ? last - ref : null;
} }
function formatDelta(val) { function formatDelta(val) {
if (val === null) return '-'; if (val === null) return 'Pas de données';
if (val === 0) return '0'; if (val === 0) return '0';
return (val > 0 ? '+' : '') + val; return (val > 0 ? '+' : '') + val;
} }
const delta7dCount = formatDelta(getDelta(countData, 7)); const delta7dCount = getDelta(countData, 7);
const infoDiv = document.createElement('div'); const infoDiv = document.createElement('div');
infoDiv.className = 'mt-3 alert alert-info'; infoDiv.className = 'mt-3 alert ' + (delta7dCount === null ? 'alert-secondary' : 'alert-info');
infoDiv.innerHTML = `<strong>Progression sur 7 jours :</strong> ${delta7dCount}`; infoDiv.innerHTML = `<strong>Progression sur 7 jours :</strong> ${delta7dCount === null ? '<span title="Données insuffisantes pour calculer la progression">Aucune donnée</span>' : (delta7dCount > 0 ? '+' + delta7dCount : delta7dCount === 0 ? '0' : delta7dCount)}`;
canvas.parentNode.appendChild(infoDiv); canvas.parentNode.appendChild(infoDiv);
</script> </script>
{% endblock %} {% endblock %}

View file

@ -4,16 +4,18 @@
{% block stylesheets %} {% block stylesheets %}
{{ parent() }} {{ parent() }}
<link href='{{ asset('js/maplibre/maplibre-gl.css') }}' rel='stylesheet' /> <link href='{{ asset('js/maplibre/maplibre-gl.css') }}' rel='stylesheet'/>
<style> <style>
.hidden { .hidden {
display: none; display: none;
} }
#mapDashboard { #mapDashboard {
height: 400px; height: 400px;
width: 100%; width: 100%;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.suggestion-list { .suggestion-list {
position: absolute; position: absolute;
background: white; background: white;
@ -25,28 +27,35 @@
z-index: 1000; z-index: 1000;
display: none; display: none;
} }
.suggestion-item { .suggestion-item {
padding: 8px 12px; padding: 8px 12px;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
.suggestion-item:hover { .suggestion-item:hover {
background-color: #f5f5f5; background-color: #f5f5f5;
} }
.suggestion-name { .suggestion-name {
font-weight: bold; font-weight: bold;
} }
.suggestion-details { .suggestion-details {
font-size: 0.9em; font-size: 0.9em;
color: #666; color: #666;
} }
.suggestion-type { .suggestion-type {
margin-right: 8px; margin-right: 8px;
} }
.search-container { .search-container {
position: relative; position: relative;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.bubble-chart-container { .bubble-chart-container {
min-height: 300px; min-height: 300px;
@ -55,6 +64,7 @@
overflow-x: auto; overflow-x: auto;
} }
} }
@media (min-width: 769px) { @media (min-width: 769px) {
.bubble-chart-container { .bubble-chart-container {
min-height: 400px; min-height: 400px;
@ -68,81 +78,83 @@
{% block body %} {% block body %}
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<h1 class="mb-4">Tableau de bord</h1> <h1 class="mb-4">Tableau de bord</h1>
</div>
</div>
<div class="row mb-2">
<div class="col-12 text-end">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input" type="checkbox" id="toggleBubbleSize" checked>
<label class="form-check-label" for="toggleBubbleSize">
Taille des bulles proportionnelle au nombre de lieux
</label>
</div> </div>
</div> </div>
</div>
<div class="row mb-4"> <div class="row mb-2">
<div class="col-12"> <div class="col-12 text-end">
<div class="card"> <div class="form-check form-switch d-inline-block">
<div class="card-header"> <input class="form-check-input" type="checkbox" id="toggleBubbleSize" checked>
Statistiques des villes (bubble chart) <label class="form-check-label" for="toggleBubbleSize">
</div> Taille des bulles proportionnelle au nombre de lieux
<div class="card-body bubble-chart-container"> </label>
<canvas id="bubbleChart" style="width: 100%; height: 100%; min-height: 300px;"></canvas>
</div>
<p>Plus une ville est en haut, plus ses informations sont complètes. Plus elle est à droite, plus elle à été modifiée récemment en moyenne. La taille de la bulle donne le nombre de lieux d'intérêt repérés dans la ville.</p>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
Fraîcheur des données OSM (âge moyen par trimestre)
</div>
<div class="card-body">
<canvas id="freshnessHistogram" style="min-height: 300px; width: 100%;"></canvas>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="row"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-header">
<h3 class="card-title">Labourer une ville</h3> Statistiques des villes (bubble chart)
<form id="labourerForm"> </div>
<div class="search-container"> <div class="card-body bubble-chart-container">
<input type="text" <canvas id="bubbleChart" style="width: 100%; height: 100%; min-height: 300px;"></canvas>
id="citySearch" </div>
class="form-control" <p>Plus une ville est en haut, plus ses informations sont complètes. Plus elle est à droite, plus
placeholder="Rechercher une ville..." elle à été modifiée récemment en moyenne. La taille de la bulle donne le nombre de lieux
autocomplete="off"> d'intérêt repérés dans la ville.</p>
<div id="citySuggestions" class="suggestion-list"></div>
</div>
<input type="hidden" name="zip_code" id="selectedZipCode">
<button type="submit" class="btn btn-primary mt-3">Labourer cette ville</button>
</form>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="row mt-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<h2>Statistiques par ville</h2> <div class="card">
<div class="card-header">
<div class="table-container" > Fraîcheur des données OSM (âge moyen par trimestre)
<div class="table-responsive"> </div>
<table id="dashboard-table" class="table table-striped table-sort"> <div class="card-body">
<thead> <canvas id="freshnessHistogram" style="min-height: 300px; width: 100%;"></canvas>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<h3 class="card-title">Labourer une ville</h3>
<form id="labourerForm">
<div class="search-container">
<input type="text"
id="citySearch"
class="form-control"
placeholder="Rechercher une ville..."
autocomplete="off">
<div id="citySuggestions" class="suggestion-list"></div>
</div>
<input type="hidden" name="zip_code" id="selectedZipCode">
<button type="submit" class="btn btn-primary mt-3">Labourer cette ville</button>
</form>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h2>Statistiques par ville</h2>
<div class="table-container">
<div class="table-responsive">
<table id="dashboard-table" class="table table-striped table-sort">
<thead>
<tr> <tr>
<th>Ville</th> <th>Ville</th>
<th>Code postal</th> <th>Code postal</th>
@ -155,11 +167,13 @@
<th>Date moyenne de mise à jour</th> <th>Date moyenne de mise à jour</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for stat in stats_list %} {% for stat in stats_list %}
<tr> {% if stat.zone != 'undefined' and stat.zone matches '/^\\d+$/' %}
<td><a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}" title="Voir les statistiques de cette ville"> <tr>
<td><a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}"
title="Voir les statistiques de cette ville">
{% if stat.name %} {% if stat.name %}
{{ stat.name }} {{ stat.name }}
{% else %} {% else %}
@ -174,45 +188,48 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</a></td> </a></td>
<td>{{ stat.zone }}</td> <td>{{ stat.zone }}</td>
<td>{{ stat.completionPercent }}%</td> <td>{{ stat.completionPercent }}%</td>
<td>{{ stat.placesCount }}</td> <td>{{ stat.placesCount }}</td>
<td>{{ (stat.placesCount / (stat.population or 1 ))|round(2) }}</td> <td>{{ (stat.placesCount / (stat.population or 1 ))|round(2) }}</td>
<td>{% if stat.budgetAnnuel %}{{ (stat.budgetAnnuel / 1000000)|number_format(1, '.', ' ') }} M€{% else %}-{% endif %}</td> <td>{% if stat.budgetAnnuel %}{{ (stat.budgetAnnuel / 1000000)|number_format(1, '.', ' ') }} M€{% else %}-{% endif %}</td>
<td>{% if stat.budgetAnnuel and stat.population %}{{ (stat.budgetAnnuel / stat.population)|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td> <td>{% if stat.budgetAnnuel and stat.population %}{{ (stat.budgetAnnuel / stat.population)|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td>
<td>{% if stat.budgetAnnuel and stat.placesCount %}{{ (stat.budgetAnnuel / stat.placesCount)|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td> <td>{% if stat.budgetAnnuel and stat.placesCount %}{{ (stat.budgetAnnuel / stat.placesCount)|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td>
<td>{{ stat.osmDataDateAvg|date('Y-m-d H:i') }}</td> <td>{{ stat.osmDataDateAvg|date('Y-m-d H:i') }}</td>
<td> <td>
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}" class="btn btn-sm btn-primary" title="Voir les statistiques de cette ville"> <a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}"
<i class="bi bi-eye"></i> class="btn btn-sm btn-primary"
</a> title="Voir les statistiques de cette ville">
<a href="{{ path('app_admin_labourer', {'insee_code': stat.zone, 'deleteMissing': 1}) }}" <i class="bi bi-eye"></i>
class="btn btn-sm btn-success btn-labourer" </a>
data-zip-code="{{ stat.zone }}" <a href="{{ path('app_admin_labourer', {'insee_code': stat.zone, 'deleteMissing': 1}) }}"
title="Labourer cette ville" class="btn btn-sm btn-success btn-labourer"
> data-zip-code="{{ stat.zone }}"
<i class="bi bi-recycle"></i> title="Labourer cette ville"
</a> >
<i class="bi bi-recycle"></i>
<a href="{{ path('app_admin_delete_by_zone', {'insee_code': stat.zone}) }}" </a>
class="btn btn-sm btn-danger"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette zone ?')" <a href="{{ path('app_admin_delete_by_zone', {'insee_code': stat.zone}) }}"
title="Supprimer cette ville" class="btn btn-sm btn-danger"
> onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette zone ?')"
<i class="bi bi-trash"></i> title="Supprimer cette ville"
</a> >
</div> <i class="bi bi-trash"></i>
</td> </a>
</tr> </div>
</td>
</tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}
@ -221,220 +238,220 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
<script> <script>
window.statsDataForBubble = {{ stats|raw }}; window.statsDataForBubble = {{ stats|raw }};
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
const chartCanvas = document.getElementById('bubbleChart'); const chartCanvas = document.getElementById('bubbleChart');
if (!chartCanvas) { if (!chartCanvas) {
console.warn('Canvas #bubbleChart introuvable'); console.warn('Canvas #bubbleChart introuvable');
return; return;
}
const statsData = {{ stats|raw }};
console.log('statsData', statsData);
if (!statsData || statsData.length === 0) {
chartCanvas.parentNode.innerHTML += '<div class="text-muted p-3">Aucune donnée de statistiques à afficher pour le graphique.</div>';
return;
}
const bubbleChartData = statsData.map(stat => {
const population = parseInt(stat.population, 10);
const placesCount = parseInt(stat.placesCount, 10);
const ratio = population > 0 ? (placesCount / population) * 1000 : 0;
return {
x: population,
y: ratio,
r: Math.sqrt(placesCount) * 2,
label: stat.name,
completion: stat.completionPercent || 0
};
});
// Calcul de la régression linéaire (moindres carrés)
const validPoints = bubbleChartData.filter(d => d.x > 0 && d.y > 0);
const n = validPoints.length;
let regressionLine = null, slope = 0, intercept = 0;
if (n >= 2) {
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
validPoints.forEach(d => {
sumX += Math.log10(d.x);
sumY += d.y;
sumXY += Math.log10(d.x) * d.y;
sumXX += Math.log10(d.x) * Math.log10(d.x);
});
const meanX = sumX / n;
const meanY = sumY / n;
slope = (sumXY - n * meanX * meanY) / (sumXX - n * meanX * meanX);
intercept = meanY - slope * meanX;
const xMin = Math.min(...validPoints.map(d => d.x));
const xMax = Math.max(...validPoints.map(d => d.x));
regressionLine = [
{ x: xMin, y: slope * Math.log10(xMin) + intercept },
{ x: xMax, y: slope * Math.log10(xMax) + intercept }
];
}
Chart.register(window.ChartDataLabels);
const bubbleChart = new Chart(chartCanvas.getContext('2d'), {
type: 'bubble',
data: {
datasets: [
{
label: 'Villes',
data: bubbleChartData,
backgroundColor: bubbleChartData.map(d => `rgba(75, 192, 192, ${d.completion / 100})`),
borderColor: 'rgba(75, 192, 192, 1)',
},
].filter(Boolean)
},
options: {
responsive: true,
plugins: {
datalabels: {
anchor: 'center',
align: 'center',
color: '#000',
font: {
weight: '400',
size: "10px",
},
formatter: (value, context) => {
return context.dataset.data[context.dataIndex].label;
}
},
legend: {
display: true
},
tooltip: {
callbacks: {
label: (context) => {
const d = context.raw;
if (context.dataset.type === 'line') {
return `Régression: y = ${slope.toFixed(2)} × log10(x) + ${intercept.toFixed(2)}`;
}
return [
`${d.label}`,
`Population: ${d.x.toLocaleString()}`,
`Lieux / hab: ${d.y.toFixed(2)}`,
`Total lieux: ${Math.round(Math.pow(d.r / 2, 2))}`,
`Complétion: ${d.completion}%`
];
}
}
}
},
scales: {
x: {
type: 'logarithmic',
title: {
display: true,
text: 'Population (échelle log)'
}
},
y: {
title: {
display: true,
text: 'Commerces pour 1000 habitants'
}
}
}
} }
});
// Ajout du clic sur une bulle
chartCanvas.onclick = function(evt) {
const points = bubbleChart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true);
if (points.length > 0) {
const firstPoint = points[0];
const dataIndex = firstPoint.index;
const stat = statsData[dataIndex];
if (stat && stat.zone) {
window.location.href = '/admin/stats/' + stat.zone;
}
}
};
// HISTOGRAMME FRAÎCHEUR
const freshnessCanvas = document.getElementById('freshnessHistogram');
if (freshnessCanvas) {
// Regrouper les villes par trimestre d'âge moyen OSM
const statsData = {{ stats|raw }}; const statsData = {{ stats|raw }};
const quarterCounts = {}; console.log('statsData', statsData);
const quarterCities = {};
statsData.forEach(stat => { if (!statsData || statsData.length === 0) {
if (!stat.osmDataDateAvg) return; chartCanvas.parentNode.innerHTML += '<div class="text-muted p-3">Aucune donnée de statistiques à afficher pour le graphique.</div>';
const date = new Date(stat.osmDataDateAvg); return;
if (isNaN(date)) return; }
const year = date.getFullYear(); const bubbleChartData = statsData.map(stat => {
const month = date.getMonth(); // 0-11 const population = parseInt(stat.population, 10);
const quarter = Math.floor(month / 3) + 1; const placesCount = parseInt(stat.placesCount, 10);
const key = `${year}-T${quarter}`; const ratio = population > 0 ? (placesCount / population) * 1000 : 0;
if (!quarterCounts[key]) quarterCounts[key] = 0; return {
if (!quarterCities[key]) quarterCities[key] = []; x: population,
quarterCounts[key]++; y: ratio,
quarterCities[key].push(stat.name); r: Math.sqrt(placesCount) * 2,
label: stat.name,
completion: stat.completionPercent || 0
};
}); });
// Ordonner les trimestres // Calcul de la régression linéaire (moindres carrés)
const sortedKeys = Object.keys(quarterCounts).sort(); const validPoints = bubbleChartData.filter(d => d.x > 0 && d.y > 0);
const data = sortedKeys.map(k => quarterCounts[k]); const n = validPoints.length;
const labels = sortedKeys; let regressionLine = null, slope = 0, intercept = 0;
const citiesPerQuarter = sortedKeys.map(k => quarterCities[k]); if (n >= 2) {
new Chart(freshnessCanvas.getContext('2d'), { let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
type: 'line', validPoints.forEach(d => {
fill: true, sumX += Math.log10(d.x);
tension: 0.2, sumY += d.y;
sumXY += Math.log10(d.x) * d.y;
sumXX += Math.log10(d.x) * Math.log10(d.x);
});
const meanX = sumX / n;
const meanY = sumY / n;
slope = (sumXY - n * meanX * meanY) / (sumXX - n * meanX * meanX);
intercept = meanY - slope * meanX;
const xMin = Math.min(...validPoints.map(d => d.x));
const xMax = Math.max(...validPoints.map(d => d.x));
regressionLine = [
{x: xMin, y: slope * Math.log10(xMin) + intercept},
{x: xMax, y: slope * Math.log10(xMax) + intercept}
];
}
Chart.register(window.ChartDataLabels);
const bubbleChart = new Chart(chartCanvas.getContext('2d'), {
type: 'bubble',
data: { data: {
labels: labels, datasets: [
datasets: [{ {
label: 'Nombre de villes', label: 'Villes',
data: data, data: bubbleChartData,
backgroundColor: 'rgba(54, 162, 235, 0.7)', backgroundColor: bubbleChartData.map(d => `rgba(75, 192, 192, ${d.completion / 100})`),
borderColor: 'rgba(54, 162, 235, 1)', borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1, },
datalabels: { ].filter(Boolean)
anchor: 'end',
align: 'end',
color: '#333',
font: { weight: 'bold', size: 10 },
formatter: function(value, context) {
const idx = context.dataIndex;
const villes = citiesPerQuarter[idx] || [];
if (villes.length === 0) return '';
let txt = villes.slice(0, 5).join(', ');
if (villes.length > 5) txt += ', ...';
return txt;
}
}
}]
}, },
options: { options: {
responsive: true, responsive: true,
plugins: { plugins: {
legend: { display: false }, datalabels: {
tooltip: { anchor: 'center',
callbacks: { align: 'center',
label: function(ctx) { color: '#000',
const idx = ctx.dataIndex; font: {
const villes = citiesPerQuarter[idx] || []; weight: '400',
let label = `Villes : ${ctx.parsed.y}`; size: "10px",
if (villes.length > 0) { },
label += '\n' + villes.join(', '); formatter: (value, context) => {
} return context.dataset.data[context.dataIndex].label;
return label;
}
} }
}, },
datalabels: { legend: {
display: true display: true
},
tooltip: {
callbacks: {
label: (context) => {
const d = context.raw;
if (context.dataset.type === 'line') {
return `Régression: y = ${slope.toFixed(2)} × log10(x) + ${intercept.toFixed(2)}`;
}
return [
`${d.label}`,
`Population: ${d.x.toLocaleString()}`,
`Lieux / hab: ${d.y.toFixed(2)}`,
`Total lieux: ${Math.round(Math.pow(d.r / 2, 2))}`,
`Complétion: ${d.completion}%`
];
}
}
} }
}, },
scales: { scales: {
x: { x: {
title: { display: true, text: 'Trimestre de l\'âge moyen OSM' } type: 'logarithmic',
title: {
display: true,
text: 'Population (échelle log)'
}
}, },
y: { y: {
beginAtZero: true, title: {
title: { display: true, text: 'Nombre de villes' } display: true,
text: 'Commerces pour 1000 habitants'
}
} }
} }
}, }
plugins: [ChartDataLabels]
}); });
} // Ajout du clic sur une bulle
}); chartCanvas.onclick = function (evt) {
const points = bubbleChart.getElementsAtEventForMode(evt, 'nearest', {intersect: true}, true);
if (points.length > 0) {
const firstPoint = points[0];
const dataIndex = firstPoint.index;
const stat = statsData[dataIndex];
if (stat && stat.zone) {
window.location.href = '/admin/stats/' + stat.zone;
}
}
};
// HISTOGRAMME FRAÎCHEUR
const freshnessCanvas = document.getElementById('freshnessHistogram');
if (freshnessCanvas) {
// Regrouper les villes par trimestre d'âge moyen OSM
const statsData = {{ stats|raw }};
const quarterCounts = {};
const quarterCities = {};
statsData.forEach(stat => {
if (!stat.osmDataDateAvg) return;
const date = new Date(stat.osmDataDateAvg);
if (isNaN(date)) return;
const year = date.getFullYear();
const month = date.getMonth(); // 0-11
const quarter = Math.floor(month / 3) + 1;
const key = `${year}-T${quarter}`;
if (!quarterCounts[key]) quarterCounts[key] = 0;
if (!quarterCities[key]) quarterCities[key] = [];
quarterCounts[key]++;
quarterCities[key].push(stat.name);
});
// Ordonner les trimestres
const sortedKeys = Object.keys(quarterCounts).sort();
const data = sortedKeys.map(k => quarterCounts[k]);
const labels = sortedKeys;
const citiesPerQuarter = sortedKeys.map(k => quarterCities[k]);
new Chart(freshnessCanvas.getContext('2d'), {
type: 'line',
fill: true,
tension: 0.2,
data: {
labels: labels,
datasets: [{
label: 'Nombre de villes',
data: data,
backgroundColor: 'rgba(54, 162, 235, 0.7)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
datalabels: {
anchor: 'end',
align: 'end',
color: '#333',
font: {weight: 'bold', size: 10},
formatter: function (value, context) {
const idx = context.dataIndex;
const villes = citiesPerQuarter[idx] || [];
if (villes.length === 0) return '';
let txt = villes.slice(0, 5).join(', ');
if (villes.length > 5) txt += ', ...';
return txt;
}
}
}]
},
options: {
responsive: true,
plugins: {
legend: {display: false},
tooltip: {
callbacks: {
label: function (ctx) {
const idx = ctx.dataIndex;
const villes = citiesPerQuarter[idx] || [];
let label = `Villes : ${ctx.parsed.y}`;
if (villes.length > 0) {
label += '\n' + villes.join(', ');
}
return label;
}
}
},
datalabels: {
display: true
}
},
scales: {
x: {
title: {display: true, text: 'Trimestre de l\'âge moyen OSM'}
},
y: {
beginAtZero: true,
title: {display: true, text: 'Nombre de villes'}
}
}
},
plugins: [ChartDataLabels]
});
}
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -4,7 +4,7 @@
{% block stylesheets %} {% block stylesheets %}
{{ parent() }} {{ parent() }}
<style> <style>
.city-list { .city-list {
display: grid; display: grid;
@ -12,16 +12,19 @@
gap: 1rem; gap: 1rem;
margin: 2rem 0; margin: 2rem 0;
} }
.city-item { .city-item {
padding: 1rem; padding: 1rem;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
text-align: center; text-align: center;
} }
.city-item a { .city-item a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
} }
.suggestion-list { .suggestion-list {
position: absolute; position: absolute;
background: white; background: white;
@ -33,33 +36,41 @@
z-index: 1000; z-index: 1000;
display: none; display: none;
} }
.suggestion-item { .suggestion-item {
padding: 8px 12px; padding: 8px 12px;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
.suggestion-item:hover { .suggestion-item:hover {
background-color: #f5f5f5; background-color: #f5f5f5;
} }
.suggestion-name { .suggestion-name {
font-weight: bold; font-weight: bold;
} }
.suggestion-details { .suggestion-details {
font-size: 0.9em; font-size: 0.9em;
color: #666; color: #666;
} }
.suggestion-type { .suggestion-type {
margin-right: 8px; margin-right: 8px;
} }
.search-container { .search-container {
position: relative; position: relative;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.list-group-item{
.list-group-item {
cursor: pointer; cursor: pointer;
} }
.list-group-item:hover{
.list-group-item:hover {
background-color: #f5f5f5; background-color: #f5f5f5;
color: #000; color: #000;
} }
@ -67,68 +78,77 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="container mt-4"> <div class="container mt-4">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<h1> <h1>
<i class="bi bi-shop"></i> Mon Commerce OSM <i class="bi bi-shop"></i> Mon Commerce OSM
</h1> </h1>
<p class="mt-4 p-4"> <p class="mt-4 p-4">
Bonjour, ce site permet de modifier les informations de votre commerce sur OpenStreetMap afin de gagner en visibilité sur des milliers de sites web à la fois en une minute, c'est gratuit et sans engagement. Bonjour, ce site permet de modifier les informations de votre commerce sur OpenStreetMap afin de
<br>Nous sommes bénévoles dans une association à but non lucratif. gagner en visibilité sur des milliers de sites web à la fois en une minute, c'est gratuit et sans
<br>Nous vous enverrons un lien unique pour cela par email, et si vous en avez besoin, nous pouvons vous aider. engagement.
</p> <br>Nous sommes bénévoles dans une association à but non lucratif.
<br>Nous vous enverrons un lien unique pour cela par email, et si vous en avez besoin, nous pouvons
vous aider.
</p>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<label class="label" for="researchShop"> <label class="label" for="researchShop">
<i class="bi bi-search bi-2x"></i> Rechercher un commerce, écrivez son nom et la ville <i class="bi bi-search bi-2x"></i> Rechercher un commerce, écrivez son nom et la ville
</label> </label>
<input class="form-control" type="text" id="researchShop" placeholder="Mon commerce, Paris"> <input class="form-control" type="text" id="researchShop" placeholder="Mon commerce, Paris">
</div>
</div> </div>
</div>
<div id="resultsList"></div>
<div id="proposeLink" class="d-none"></div>
<div id="proposeMail" class="d-none">
<input type="email" id="emailInput" class="form-control" placeholder="mon_email_de_commerce@exemple.com">
<button type="submit" class="btn btn-primary p-4 d-block"> <i class="bi bi-envelope"></i> Envoyer</button>
</div>
<div id="emailForm"></div>
<div id="resultsList"></div>
<div id="proposeLink" class="d-none"></div>
<div id="proposeMail" class="d-none">
<input type="email" id="emailInput" class="form-control"
placeholder="mon_email_de_commerce@exemple.com">
<button type="submit" class="btn btn-primary p-4 d-block"><i class="bi bi-envelope"></i> Envoyer
</button>
</div>
<div id="emailForm"></div>
</div>
</div>
<div class="row city-list ">
<div id="stats_bubble"></div>
<div class="mt-5">
<h2><i class="bi bi-geo-alt"></i> Villes disponibles</h2>
<p>Visualisez un tableau de bord de la complétion des commerces et autres lieux d'intérêt pour votre
ville grâce à OpenStreetMap</p>
</div>
{% set sorted_stats = stats|sort((a, b) => a.zone <=> b.zone) %}
{% for stat in sorted_stats %}
{% if stat.zone != 'undefined' and stat.zone matches '/^\\d+$/' %}
<a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}"
class="list-group-item list-group-item-action d-flex p-4 rounded-3 justify-content-between align-items-center">
<div class="d-flex flex-column">
<span class="zone">{{ stat.zone }}</span>
<span class="name">{{ stat.name }}</span>
</div>
<div class="d-flex flex-column">
<span class="badge bg-primary rounded-pill">{{ stat.placesCount }} lieux</span>
<span class="badge rounded-pill completion {% if stat.completionPercent > 80 %}bg-success{% else %}bg-info{% endif %}">{{ stat.completionPercent }}%</span>
</div>
</a>
{% endif %}
{% endfor %}
{% include 'public/labourage-form.html.twig' %}
</div> </div>
</div> </div>
<div class="row city-list ">
<div id="stats_bubble"></div>
<div class="mt-5">
<h2><i class="bi bi-geo-alt"></i> Villes disponibles</h2>
<p>Visualisez un tableau de bord de la complétion des commerces et autres lieux d'intérêt pour votre ville grâce à OpenStreetMap</p>
</div>
{% set sorted_stats = stats|sort((a, b) => a.zone <=> b.zone) %}
{% for stat in sorted_stats %}
{% if stat.zone != 'undefined' %}
<a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}" class="list-group-item list-group-item-action d-flex p-4 rounded-3 justify-content-between align-items-center">
<div class="d-flex flex-column">
<span class="zone">{{ stat.zone }}</span>
<span class="name">{{ stat.name }}</span>
</div>
<div class="d-flex flex-column">
<span class="badge bg-primary rounded-pill">{{ stat.placesCount }} lieux</span>
<span class="badge rounded-pill completion {% if stat.completionPercent > 80 %}bg-success{% else %}bg-info{% endif %}" >{{ stat.completionPercent }}%</span>
</div>
</a>
{% endif %}
{% endfor %}
{% include 'public/labourage-form.html.twig' %}
</div>
</div>
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
{# <script src='{{ asset('js/utils.js') }}'></script> #} {# <script src='{{ asset('js/utils.js') }}'></script> #}
<script type="module"> <script type="module">
// import { adjustListGroupFontSize } from '{{ asset('js/utils.js') }}'; // import { adjustListGroupFontSize } from '{{ asset('js/utils.js') }}';
// document.addEventListener('DOMContentLoaded', function() { // document.addEventListener('DOMContentLoaded', function() {
@ -136,9 +156,9 @@
// }); // });
</script> </script>
<script> <script>
// Créer le formulaire email // Créer le formulaire email
const emailFormHtml = ` const emailFormHtml = `
<form id="emailForm" class="mt-3"> <form id="emailForm" class="mt-3">
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Email</label> <label for="email" class="form-label">Email</label>
@ -148,102 +168,101 @@
</form> </form>
`; `;
// Créer les divs pour les messages // Créer les divs pour les messages
const proposeLinkHtml = ` const proposeLinkHtml = `
<div id="proposeLink" class="alert alert-success "> <div id="proposeLink" class="alert alert-success ">
Un email a déjà été enregistré pour ce commerce. Nous vous enverrons le lien de modification. Un email a déjà été enregistré pour ce commerce. Nous vous enverrons le lien de modification.
</div> </div>
`; `;
const proposeMailHtml = ` const proposeMailHtml = `
<div id="proposeMail" class="alert alert-info " > <div id="proposeMail" class="alert alert-info " >
Aucun email n'est enregistré pour ce commerce. Veuillez saisir votre email pour recevoir le lien de modification. Aucun email n'est enregistré pour ce commerce. Veuillez saisir votre email pour recevoir le lien de modification.
${emailFormHtml} ${emailFormHtml}
</div> </div>
`; `;
// Ajouter les éléments au DOM // Ajouter les éléments au DOM
document.querySelector('#proposeLink').innerHTML = proposeLinkHtml; document.querySelector('#proposeLink').innerHTML = proposeLinkHtml;
document.querySelector('#proposeMail').innerHTML = proposeMailHtml; document.querySelector('#proposeMail').innerHTML = proposeMailHtml;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
const searchInput = document.querySelector('#researchShop'); const searchInput = document.querySelector('#researchShop');
const resultsList = document.querySelector('#resultsList'); const resultsList = document.querySelector('#resultsList');
resultsList.classList.add('list-group', 'mt-2'); resultsList.classList.add('list-group', 'mt-2');
let timeoutId; let timeoutId;
searchInput.addEventListener('input', function(e) { searchInput.addEventListener('input', function (e) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
resultsList.innerHTML = ''; resultsList.innerHTML = '';
if (e.target.value.length < 3) return;
timeoutId = setTimeout(() => { if (e.target.value.length < 3) return;
fetch(`https://demo.addok.xyz/search?q=${e.target.value}&limit=5`)
.then(response => response.json()) timeoutId = setTimeout(() => {
.then(data => { fetch(`https://demo.addok.xyz/search?q=${e.target.value}&limit=5`)
resultsList.innerHTML = ''; .then(response => response.json())
const ul = document.createElement('ul'); .then(data => {
ul.classList.add('list-group'); resultsList.innerHTML = '';
resultsList.appendChild(ul); const ul = document.createElement('ul');
ul.classList.add('list-group');
data.features.forEach(feature => { resultsList.appendChild(ul);
const li = document.createElement('li');
li.classList.add('list-group-item', 'cursor-pointer'); data.features.forEach(feature => {
li.textContent = `${feature.properties.name}, ${feature.properties.city}`; const li = document.createElement('li');
li.classList.add('list-group-item', 'cursor-pointer');
li.addEventListener('click', () => { li.textContent = `${feature.properties.name}, ${feature.properties.city}`;
resultsList.innerHTML = ''; // Cacher la liste
const [lon, lat] = feature.geometry.coordinates; li.addEventListener('click', () => {
const query = `[out:json]; resultsList.innerHTML = ''; // Cacher la liste
const [lon, lat] = feature.geometry.coordinates;
const query = `[out:json];
( (
node["shop"](around:100,${lat},${lon}); node["shop"](around:100,${lat},${lon});
node["amenity"](around:100,${lat},${lon}); node["amenity"](around:100,${lat},${lon});
); );
out body;`; out body;`;
fetch('https://overpass-api.de/api/interpreter', { fetch('https://overpass-api.de/api/interpreter', {
method: 'POST', method: 'POST',
body: query body: query
}) })
.then(response => response.json()) .then(response => response.json())
.then(osmData => { .then(osmData => {
if (osmData.elements.length > 0) { if (osmData.elements.length > 0) {
const place = osmData.elements[0]; const place = osmData.elements[0];
console.log(`https://www.openstreetmap.org/${place.type}/${place.id}` , place.tags); console.log(`https://www.openstreetmap.org/${place.type}/${place.id}`, place.tags);
if (place.tags && (place.tags['contact:email'] || place.tags['email'])) {
if (place.tags && (place.tags['contact:email'] || place.tags['email'])) { document.querySelector('#proposeLink').classList.remove('d-none');
document.querySelector('#proposeLink').classList.remove('d-none'); document.querySelector('#proposeMail').classList.add('d-none');
document.querySelector('#proposeMail').classList.add('d-none'); } else {
} else { document.querySelector('#proposeMail').classList.remove('d-none');
document.querySelector('#proposeMail').classList.remove('d-none'); document.querySelector('#proposeLink').classList.add('d-none');
document.querySelector('#proposeLink').classList.add('d-none');
const emailForm = document.querySelector('#proposeMail form');
const emailForm = document.querySelector('#proposeMail form'); emailForm.addEventListener('submit', (e) => {
emailForm.addEventListener('submit', (e) => { e.preventDefault();
e.preventDefault(); const email = emailForm.querySelector('#emailInput').value;
const email = emailForm.querySelector('#emailInput').value; window.location.href = `/propose-email/${email}/${place.type}/${place.id}`;
window.location.href = `/propose-email/${email}/${place.type}/${place.id}`; });
}); }
} }
} });
}); });
ul.appendChild(li);
}); });
ul.appendChild(li);
}); });
}); }, 500);
}, 500); });
});
function displayStatsBubble(){ function displayStatsBubble() {
const statsBubble = document.querySelector('#stats_bubble'); const statsBubble = document.querySelector('#stats_bubble');
}
}); }
</script>
});
</script>
{% endblock %} {% endblock %}