@jjlmoya/utils-civic 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -0
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/participatory-budget-allocator/bibliography.astro +6 -0
  8. package/src/tool/participatory-budget-allocator/bibliography.ts +8 -0
  9. package/src/tool/participatory-budget-allocator/component.astro +61 -0
  10. package/src/tool/participatory-budget-allocator/contract.test.ts +13 -0
  11. package/src/tool/participatory-budget-allocator/controller.ts +108 -0
  12. package/src/tool/participatory-budget-allocator/dom-views.test.ts +14 -0
  13. package/src/tool/participatory-budget-allocator/dom-views.ts +58 -0
  14. package/src/tool/participatory-budget-allocator/entry.ts +27 -0
  15. package/src/tool/participatory-budget-allocator/evaluator.ts +24 -0
  16. package/src/tool/participatory-budget-allocator/i18n/de.ts +44 -0
  17. package/src/tool/participatory-budget-allocator/i18n/en.ts +79 -0
  18. package/src/tool/participatory-budget-allocator/i18n/es.ts +43 -0
  19. package/src/tool/participatory-budget-allocator/i18n/fr.ts +43 -0
  20. package/src/tool/participatory-budget-allocator/i18n/id.ts +43 -0
  21. package/src/tool/participatory-budget-allocator/i18n/it.ts +43 -0
  22. package/src/tool/participatory-budget-allocator/i18n/ja.ts +43 -0
  23. package/src/tool/participatory-budget-allocator/i18n/ko.ts +43 -0
  24. package/src/tool/participatory-budget-allocator/i18n/nl.ts +43 -0
  25. package/src/tool/participatory-budget-allocator/i18n/pl.ts +43 -0
  26. package/src/tool/participatory-budget-allocator/i18n/pt.ts +43 -0
  27. package/src/tool/participatory-budget-allocator/i18n/ru.ts +43 -0
  28. package/src/tool/participatory-budget-allocator/i18n/sv.ts +43 -0
  29. package/src/tool/participatory-budget-allocator/i18n/tr.ts +43 -0
  30. package/src/tool/participatory-budget-allocator/i18n/zh.ts +43 -0
  31. package/src/tool/participatory-budget-allocator/index.ts +11 -0
  32. package/src/tool/participatory-budget-allocator/logic.test.ts +48 -0
  33. package/src/tool/participatory-budget-allocator/logic.ts +146 -0
  34. package/src/tool/participatory-budget-allocator/participatory-budget-allocator.css +426 -0
  35. package/src/tool/participatory-budget-allocator/seo.astro +14 -0
  36. package/src/tool/participatory-budget-allocator/storage.ts +32 -0
  37. package/src/tool/participatory-budget-allocator/ui.ts +88 -0
  38. package/src/tools.ts +2 -0
@@ -0,0 +1,43 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import { bibliography } from '../bibliography';
3
+ import type { ParticipatoryBudgetLocaleContent } from '../entry';
4
+ import type { ParticipatoryBudgetUI } from '../ui';
5
+
6
+ const ui: ParticipatoryBudgetUI = {
7
+ budgetLabel: 'Tillgänglig budget', budgetHelp: 'Använd en valuta och ange taket som inte får överskridas.', proposalsHeading: 'Lägg till förslag med jämförbar poäng', proposalNameLabel: 'Förslagets namn', proposalCostLabel: 'Kostnad', proposalScoreLabel: 'Röster eller nyttopoäng', addProposal: 'Lägg till förslag', removeProposal: 'Ta bort', calculateNote: 'Urvalet uppdateras när du redigerar raderna. Varje förslag finansieras helt eller lämnas utanför.', loadExample: 'Läs in exempel', printAction: 'Skriv ut granskningsblad', resultHeading: 'Budgetmosaik', emptyResult: 'Lägg till förslag för att se vilket komplett paket som ryms i budgeten.', excludedLabel: 'Inte finansierat', spentLabel: 'Total kostnad', remainingLabel: 'Återstående budget', scoreLabel: 'Total poäng', proposalCountLabel: 'förslag', selectedStatus: 'Finansierat', tooExpensiveStatus: 'Ryms inte', notSelectedStatus: 'Bortvalt', invalidStatus: 'Behöver rättas', reasonLabel: 'Varför', methodHeading: 'Använd metod', methodText: 'Verktyget löser en 0/1-ryggsäcksmodell. Varje förslag har en kostnad och en jämförbar poäng. Det söker möjliga kombinationer, maximerar poängen och använder sedan lägre kostnad, färre förslag och tidigare inmatningsordning som bestämda skiljeregler.', limitsHeading: 'Det här gör verktyget inte', limitsText: 'Det avgör inte om ett förslag är rättvist, lagligt, berättigat, genomförbart eller socialt bättre. Det ersätter inte regler för medborgarbudget eller en omröstning.', warningsHeading: 'Gränsfall och datavarningar', warningsText: 'Håll poäng jämförbara och använd samma kostnadsgrund. Nollor, dubbla namn, ofullständiga uppskattningar, beroenden och delbara nyttor kräver mänsklig granskning.', noProposalsWarning: 'Inga förslag har angetts.', noFeasibleWarning: 'Inget förslag ryms i den tillgängliga budgeten.', invalidBudget: 'Ange en icke-negativ budget. Webbläsarmodellen accepterar upp till 20 000,00 i vald valuta.', budgetTooLarge: 'Budgeten överstiger modellens gräns på 20 000,00. Sänk taket eller dela upp övningen.', invalidProposal: 'Varje rad behöver ett namn, en icke-negativ kostnad och en icke-negativ poäng.', costExceedsBudget: 'Den här kostnaden överstiger den tillgängliga budgeten. Förslaget kan inte väljas.', tieBreakLabel: 'Bestämd regel', budgetUsedLabel: 'använt', budgetLeftLabel: 'kvar', selectedTilesLabel: 'Valda block', allocationLabel: 'Budgetkapacitet', statusSaved: 'Utkastet sparades lokalt i den här webbläsaren.',
8
+ };
9
+ const faq = [
10
+ { question: 'Vad optimerar den här fördelaren?', answer: 'Den väljer en genomförbar uppsättning hela förslag som maximerar den totala poäng du anger utan att överskrida taket. Den avgör inte om poängen är rättvis eller meningsfull.' },
11
+ { question: 'Varför är förslagen allt eller inget?', answer: 'Den första versionen använder en 0/1-modell. Ett förslag finansieras till full kostnad eller utesluts, vilket passar projekt vars nytta kräver ett helt genomförande.' },
12
+ { question: 'Vad händer när två budgetar får samma poäng?', answer: 'Först väljs lägre totalkostnad, sedan färre förslag och sist tidigare inmatningsordning. Därför går resultatet att återskapa.' },
13
+ { question: 'Kan resultatet bestämma en riktig budget?', answer: 'Nej. Det är ett transparent scenario. Kontrollera behörighet, beroenden, genomförandekapacitet, valuta och den officiella processen.' },
14
+ ];
15
+ const howTo = [
16
+ { name: 'Ange taket', text: 'Skriv in beloppet som finns i den här omgången och använd samma kostnadsgrund.' },
17
+ { name: 'Lägg till hela förslag', text: 'Skriv in varje förslag en gång med full kostnad och jämförbar röst- eller nyttopoäng.' },
18
+ { name: 'Granska mosaiken', text: 'Följ finansierade förslag, poäng, kostnad och återstående budget när raderna ändras.' },
19
+ { name: 'Läs uteslutningarna', text: 'Skilj förslag som är för dyra på egen hand från dem som förlorade mot en bättre genomförbar kombination.' },
20
+ { name: 'Gör mänskliga kontroller', text: 'Jämför scenariot med regler, beroenden, kapacitet och berörda personer före mötet.' },
21
+ ];
22
+ const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Fördelare för medborgarbudget', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: 'Välj hela offentliga förslag under en fast budgetgräns med en transparent optimeringsregel.', url: 'https://gamebob.dev/sv/fordelare-for-deltagande-budget', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } };
23
+ const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
24
+ const howToSchema: HowTo = { '@type': 'HowTo', name: 'Fördela en fast medborgarbudget', step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) };
25
+
26
+ export const content: ParticipatoryBudgetLocaleContent = { slug: 'fordelare-for-deltagande-budget', title: 'Fördelare för medborgarbudget', description: 'Välj hela förslag under en fast budgetgräns, maximera en angiven poäng och se kostnaden och skälet bakom varje uteslutning.', ui, seo: [
27
+ { type: 'title', text: 'Gör en budgetgräns till ett reproducerbart scenario', level: 2 },
28
+ { type: 'paragraph', html: 'I en offentlig budgetomgång finns ofta fler värdefulla förslag än pengar. Fördelaren gör avvägningen tydlig: ange taket, lägg till hela förslag och ge varje förslag jämförbara röster eller nyttopoäng. Resultatet visar ett möjligt paket i stället för att gömma urvalet bakom ett enda råd.' },
29
+ { type: 'title', text: 'Så beräknas urvalet', level: 2 },
30
+ { type: 'paragraph', html: 'Modellen är ett 0/1-ryggsäcksproblem. Webbläsaren jämför kombinationer som håller sig under taket och behåller den med högst total poäng. Vid lika poäng används lägre kostnad, färre förslag och tidigare inmatning. Poängen är det valda målet, inte ett oberoende mått på offentligt värde.' },
31
+ { type: 'table', headers: ['Fält', 'Betydelse', 'Kontrollfråga'], rows: [['Kostnad', 'Hela beloppet för att genomföra förslaget', 'Använder alla rader samma grund?'], ['Poäng', 'Angivna röster eller nyttovärde', 'Går värdena att jämföra?'], ['Budget', 'Högsta totalkostnad för urvalet', 'Omfattar taket samma kostnader?'], ['Uteslutning', 'För dyrt ensamt eller besegrat av ett bättre paket', 'Är skälet matematiskt eller politiskt?']] },
32
+ { type: 'title', text: 'Förbered indata utan missvisande antaganden', level: 2 },
33
+ { type: 'list', items: ['Bekräfta att alla förslag gäller samma budgetomgång.', 'Använd full genomförandekostnad eller verkligt självständiga steg.', 'Dokumentera hur röster och nyttopoäng togs fram.', 'Kontrollera beroenden, underhåll, juridik och kapacitet separat.', 'Spara urval och uteslutningsskäl som granskningsunderlag.'] },
34
+ { type: 'tip', title: 'En poäng är ett valt mål', html: 'Om poängen är röster maximerar resultatet rösterna. Om den är nytta maximerar det den bedömningen. När poängen ändras ändras frågan, så spara metoden tillsammans med resultatet.' },
35
+ { type: 'title', text: 'Läs finansierade och uteslutna paket', level: 2 },
36
+ { type: 'paragraph', html: 'Finansierade förslag visar kostnad, poäng och återstående budget. Ett förslag som inte ryms är dyrare än taket på egen hand. Ett bortvalt förslag kan rymmas ensamt men ingår inte i den bästa kombinationen enligt det valda målet och skiljereglerna.' },
37
+ { type: 'title', text: 'Begränsningar och nästa kontroller', level: 2 },
38
+ { type: 'paragraph', html: 'Fördelaren bedömer inte rättvisa mellan områden, genomföranderisk, projektkonflikter eller juridisk behörighet. Modellen är begränsad till 20 000,00 för att den exakta sökningen ska vara snabb.' },
39
+ { type: 'paragraph', html: 'En kombination med hög poäng är bara tillförlitlig när kostnadsgrunden och poängmetoden också är tydliga.' },
40
+ { type: 'title', text: 'Använd den återstående budgeten i samtalet', level: 2 },
41
+ { type: 'paragraph', html: 'Ett överskott kan betyda att inget ytterligare förslag förbättrar det aktuella urvalet. Det kan också visa att ett berättigat förslag saknas eller att pengar avsiktligt sparas.' },
42
+ { type: 'tip', title: 'Kontrollera före beslut', html: 'Läs det matematiska urvalet tillsammans med officiella deltaganderegler och rösterna från berörda personer.' },
43
+ ], faq, bibliography, howTo, schemas: [softwareApplication, faqSchema, howToSchema] as unknown as Record<string, unknown>[] };
@@ -0,0 +1,43 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import { bibliography } from '../bibliography';
3
+ import type { ParticipatoryBudgetLocaleContent } from '../entry';
4
+ import type { ParticipatoryBudgetUI } from '../ui';
5
+
6
+ const ui: ParticipatoryBudgetUI = {
7
+ budgetLabel: 'Kullanılabilir bütçe', budgetHelp: 'Tek bir para birimi kullanın ve aşılmaması gereken üst sınırı girin.', proposalsHeading: 'Karşılaştırılabilir puanla öneri ekleyin', proposalNameLabel: 'Öneri adı', proposalCostLabel: 'Maliyet', proposalScoreLabel: 'Oy veya fayda puanı', addProposal: 'Öneri ekle', removeProposal: 'Kaldır', calculateNote: 'Satırları düzenledikçe seçim güncellenir. Her öneri ya tamamen finanse edilir ya da dışarıda kalır.', loadExample: 'Örneği yükle', printAction: 'İnceleme sayfasını yazdır', resultHeading: 'Bütçe mozaiği', emptyResult: 'Bütçeye sığan tam kombinasyonu görmek için öneri ekleyin.', excludedLabel: 'Finanse edilmedi', spentLabel: 'Toplam maliyet', remainingLabel: 'Kalan bütçe', scoreLabel: 'Toplam puan', proposalCountLabel: 'öneri', selectedStatus: 'Finanse edildi', tooExpensiveStatus: 'Sığmıyor', notSelectedStatus: 'Dışarıda', invalidStatus: 'Düzeltilmeli', reasonLabel: 'Neden', methodHeading: 'Uygulanan yöntem', methodText: 'Araç 0/1 sırt çantası modelini çözer. Her önerinin bir maliyeti ve karşılaştırılabilir bir puanı vardır. Uygun kombinasyonları arar, puanı en yükseğe çıkarır ve eşitlikte daha düşük maliyeti, daha az öneriyi ve daha erken giriş sırasını kullanır.', limitsHeading: 'Bu araç ne yapmaz', limitsText: 'Bir önerinin adil, yasal, uygun, uygulanabilir veya toplum için daha iyi olup olmadığına karar vermez. Katılımcı bütçeleme kurallarının ya da oylamanın yerini tutmaz.', warningsHeading: 'Sınır durumları ve veri uyarıları', warningsText: 'Puanları karşılaştırılabilir tutun ve aynı maliyet temelini kullanın. Sıfırlar, yinelenen adlar, eksik tahminler, bağımlılıklar ve bölünebilir faydalar insan incelemesi gerektirir.', noProposalsWarning: 'Hiç öneri girilmedi.', noFeasibleWarning: 'Kullanılabilir bütçeye sığan öneri yok.', invalidBudget: 'Negatif olmayan bir bütçe girin. Tarayıcı modeli seçilen para biriminde 20.000,00 değerine kadar çalışır.', budgetTooLarge: 'Bütçe 20.000,00 model sınırını aşıyor. Üst sınırı azaltın veya çalışmayı bölün.', invalidProposal: 'Her satırda ad, negatif olmayan maliyet ve negatif olmayan puan bulunmalıdır.', costExceedsBudget: 'Bu maliyet kullanılabilir bütçeyi aşıyor. Öneri seçilemez.', tieBreakLabel: 'Belirleyici kural', budgetUsedLabel: 'kullanıldı', budgetLeftLabel: 'kaldı', selectedTilesLabel: 'Seçilen bloklar', allocationLabel: 'Bütçe kapasitesi', statusSaved: 'Taslak bu tarayıcıya yerel olarak kaydedildi.',
8
+ };
9
+ const faq = [
10
+ { question: 'Bu dağıtıcı neyi optimize eder?', answer: 'Üst sınırı aşmadan girdiğiniz toplam puanı en yükseğe çıkaran, uygulanabilir ve tam öneri kümesini seçer. Puanın adil veya anlamlı olup olmadığına karar vermez.' },
11
+ { question: 'Öneriler neden ya tamamen seçilir ya da hiç seçilmez?', answer: 'İlk sürüm 0/1 modeli kullanır. Her öneri tam maliyetiyle uygulanır veya dışarıda bırakılır; bu, faydası tamamlanmasına bağlı projelere uyar.' },
12
+ { question: 'İki dağıtımın puanı aynıysa ne olur?', answer: 'Önce daha düşük toplam maliyet, sonra daha az öneri ve son olarak daha erken giriş sırası seçilir. Sonuç başka bir inceleyici tarafından tekrarlanabilir.' },
13
+ { question: 'Bu sonuç gerçek bir bütçeyi belirleyebilir mi?', answer: 'Hayır. Bu şeffaf bir senaryodur. Uygunluğu, bağımlılıkları, uygulama kapasitesini, para birimi temelini ve resmî süreci kontrol edin.' },
14
+ ];
15
+ const howTo = [
16
+ { name: 'Üst sınırı belirleyin', text: 'Bu turdaki kullanılabilir tutarı girin ve maliyet temelini tutarlı tutun.' },
17
+ { name: 'Tam öneriler ekleyin', text: 'Her öneriyi bir kez, tam maliyeti ve karşılaştırılabilir oy veya fayda puanıyla girin.' },
18
+ { name: 'Mozaiği inceleyin', text: 'Satırlar değiştikçe finanse edilen kümeyi, puanı, maliyeti ve kalan bütçeyi izleyin.' },
19
+ { name: 'Dışarıda kalanları okuyun', text: 'Tek başına fazla pahalı olanları daha iyi bir uygulanabilir kombinasyona kaybedenlerden ayırın.' },
20
+ { name: 'İnsan kontrollerini uygulayın', text: 'Toplantıda kullanmadan önce senaryoyu kurallar, bağımlılıklar, kapasite ve etkilenen kişilerle karşılaştırın.' },
21
+ ];
22
+ const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Katılımcı bütçe dağıtıcısı', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: 'Sabit bütçe sınırı altında tam kamu önerilerini şeffaf bir optimizasyon kuralıyla seçin.', url: 'https://gamebob.dev/tr/katilimci-butce-paylastirici', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } };
23
+ const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
24
+ const howToSchema: HowTo = { '@type': 'HowTo', name: 'Sabit katılımcı bütçesini dağıtma', step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) };
25
+
26
+ export const content: ParticipatoryBudgetLocaleContent = { slug: 'katilimci-butce-paylastirici', title: 'Katılımcı bütçe dağıtıcısı', description: 'Sabit bütçe sınırı altında tam önerileri seçin, belirlenen puanı en yükseğe çıkarın ve her dışlama için maliyeti ve nedeni görün.', ui, seo: [
27
+ { type: 'title', text: 'Bütçe sınırını tekrarlanabilir bir senaryoya dönüştürün', level: 2 },
28
+ { type: 'paragraph', html: 'Kamu bütçesi turlarında kullanılabilir paradan daha fazla değerli öneri bulunur. Bu dağıtıcı sınırı girmenizi, tam önerileri eklemenizi ve her birine karşılaştırılabilir oy veya fayda puanı vermenizi sağlar. Sonuç, seçimi tek bir gizli tavsiyenin arkasına saklamak yerine uygulanabilir bir küme gösterir.' },
29
+ { type: 'title', text: 'Seçim nasıl hesaplanır', level: 2 },
30
+ { type: 'paragraph', html: 'Model 0/1 sırt çantası problemidir. Tarayıcı sınır içinde kalan kombinasyonları karşılaştırır ve toplam puanı en yüksek olanı tutar. Eşitlikte daha düşük maliyet, daha az öneri ve daha erken giriş uygulanır. Puan, seçtiğiniz hedeftir; kamu değerinin bağımsız ölçüsü değildir.' },
31
+ { type: 'table', headers: ['Alan', 'Anlamı', 'İnceleme sorusu'], rows: [['Maliyet', 'Öneriyi uygulamak için gereken tam tutar', 'Tüm satırlar aynı temeli kullanıyor mu?'], ['Puan', 'Girilen oy veya fayda değeri', 'Değerler karşılaştırılabilir mi?'], ['Bütçe', 'Seçilen önerilerin azami toplam maliyeti', 'Sınır aynı maliyetleri kapsıyor mu?'], ['Dışlama', 'Tek başına pahalı veya daha iyi kümeye yenilmiş', 'Neden matematiksel mi, politika incelemesi mi?']] },
32
+ { type: 'title', text: 'Yanıltmayan girdiler hazırlayın', level: 2 },
33
+ { type: 'list', items: ['Tüm önerilerin aynı tur için uygun olduğunu doğrulayın.', 'Tam uygulama maliyeti veya gerçekten bağımsız aşamalar kullanın.', 'Oyların ve fayda puanlarının nasıl üretildiğini kaydedin.', 'Bağımlılıkları, bakımı, hukuki konuları ve kapasiteyi ayrı kontrol edin.', 'Seçimi ve dışlama nedenlerini inceleme kaydı olarak saklayın.'] },
34
+ { type: 'tip', title: 'Puan seçilmiş bir hedeftir', html: 'Puan oyları temsil ediyorsa sonuç toplam oyları, faydayı temsil ediyorsa o değerlendirmeyi en yükseğe çıkarır. Puanı değiştirmek soruyu da değiştirir; yöntemi sonuçla birlikte saklayın.' },
35
+ { type: 'title', text: 'Finanse edilen ve dışlanan kümeleri okuyun', level: 2 },
36
+ { type: 'paragraph', html: 'Finanse edilen öneriler maliyeti, puanı ve kalan bütçeyi gösterir. Sığmayan öneri tek başına bile sınırdan pahalıdır. Dışlanan öneri tek başına sığabilir, ancak seçilen hedef ve eşitlik kuralına göre en iyi kombinasyona girmez.' },
37
+ { type: 'title', text: 'Sınırlar ve sonraki kontroller', level: 2 },
38
+ { type: 'paragraph', html: 'Dağıtıcı bölgeler arası adaleti, uygulama riskini, proje çatışmalarını veya hukuki uygunluğu değerlendirmez. Kesin aramayı hızlı tutmak için tarayıcı modeli 20.000,00 ile sınırlıdır.' },
39
+ { type: 'paragraph', html: 'Yüksek puanlı bir kombinasyon ancak maliyet temeli ve puanlama yöntemi açıksa güvenilirdir.' },
40
+ { type: 'title', text: 'Kalan bütçeyi konuşma konusu yapın', level: 2 },
41
+ { type: 'paragraph', html: 'Kalan tutar, ek bir önerinin mevcut seçimi iyileştirmediğini gösterebilir. Eksik bir uygun öneriye veya bilinçli bir rezerve de işaret edebilir.' },
42
+ { type: 'tip', title: 'Karar vermeden kontrol edin', html: 'Matematiksel seçimi resmî katılım kuralları ve etkilenen kişilerin görüşleriyle birlikte okuyun.' },
43
+ ], faq, bibliography, howTo, schemas: [softwareApplication, faqSchema, howToSchema] as unknown as Record<string, unknown>[] };
@@ -0,0 +1,43 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import { bibliography } from '../bibliography';
3
+ import type { ParticipatoryBudgetLocaleContent } from '../entry';
4
+ import type { ParticipatoryBudgetUI } from '../ui';
5
+
6
+ const ui: ParticipatoryBudgetUI = {
7
+ budgetLabel: '可用预算', budgetHelp: '使用一种货币,并输入不能超过的上限。', proposalsHeading: '添加可比较评分的提案', proposalNameLabel: '提案名称', proposalCostLabel: '成本', proposalScoreLabel: '票数或收益评分', addProposal: '添加提案', removeProposal: '删除', calculateNote: '编辑各行时,选择结果会自动更新。每个提案要么获得全额资助,要么被排除。', loadExample: '加载示例', printAction: '打印审核单', resultHeading: '预算拼图', emptyResult: '添加提案,查看哪些完整组合能放入预算。', excludedLabel: '未获资助', spentLabel: '总成本', remainingLabel: '剩余预算', scoreLabel: '总评分', proposalCountLabel: '个提案', selectedStatus: '已资助', tooExpensiveStatus: '无法放入', notSelectedStatus: '已排除', invalidStatus: '需要修正', reasonLabel: '原因', methodHeading: '使用的方法', methodText: '工具解决0/1背包模型。每个提案都有成本和可比较的评分。它搜索可行组合,最大化总评分,然后依次使用更低成本、更少提案和更早输入顺序作为确定性平局规则。', limitsHeading: '本工具不做什么', limitsText: '它不会判断提案是否公平、合法、符合资格、可执行或更有社会价值,也不能取代参与式预算规则或投票。', warningsHeading: '边界情况和数据提醒', warningsText: '保持评分含义一致,并使用同一成本口径。零值、重复名称、不完整估算、依赖关系和可拆分收益都需要人工审核。', noProposalsWarning: '尚未输入提案。', noFeasibleWarning: '没有提案能放入可用预算。', invalidBudget: '请输入非负预算。浏览器模型支持所选货币下最高20,000.00。', budgetTooLarge: '预算超过模型上限20,000.00。请降低上限或拆分练习。', invalidProposal: '每一行都需要名称、非负成本和非负评分。', costExceedsBudget: '该成本超过可用预算,提案无法被选择。', tieBreakLabel: '确定性规则', budgetUsedLabel: '已使用', budgetLeftLabel: '剩余', selectedTilesLabel: '已选区块', allocationLabel: '预算容量', statusSaved: '草稿已保存在此浏览器中。',
8
+ };
9
+ const faq = [
10
+ { question: '这个分配工具优化什么?', answer: '它选择一组可行的完整提案,在不超过上限的情况下最大化你输入的总评分。它不会判断评分是否公平或有意义。' },
11
+ { question: '为什么提案只能全选或不选?', answer: '第一版使用0/1模型。每个提案要么按完整成本执行,要么被排除,适合必须完整交付才有价值的项目。' },
12
+ { question: '两个分配方案评分相同时怎么办?', answer: '工具先选择总成本较低的方案,再选择提案较少的方案,最后选择输入顺序较早的方案,因此结果可以复现。' },
13
+ { question: '这个结果能决定真实预算吗?', answer: '不能。这只是一个透明场景。请检查资格、依赖关系、执行能力、货币口径和正式流程。' },
14
+ ];
15
+ const howTo = [
16
+ { name: '设定上限', text: '输入本轮可用金额,并保持成本口径一致。' },
17
+ { name: '添加完整提案', text: '每个提案输入一次,填写完整成本和可比较的票数或收益评分。' },
18
+ { name: '查看拼图', text: '随着各行变化,检查已资助组合、评分、成本和剩余预算。' },
19
+ { name: '阅读排除原因', text: '区分单独就太贵的提案和输给更好可行组合的提案。' },
20
+ { name: '进行人工检查', text: '在会议中使用前,把场景与规则、依赖关系、能力和受影响人群进行比较。' },
21
+ ];
22
+ const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: '参与式预算分配工具', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: '在固定预算上限下选择完整公共提案,并使用透明的优化规则。', url: 'https://gamebob.dev/zh/participatory-budget-allocator', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } };
23
+ const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
24
+ const howToSchema: HowTo = { '@type': 'HowTo', name: '分配固定的参与式预算', step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) };
25
+
26
+ export const content: ParticipatoryBudgetLocaleContent = { slug: 'participatory-budget-allocator', title: '参与式预算分配工具', description: '在固定预算下选择完整提案,最大化声明的评分,并查看每项排除的成本和原因。', ui, seo: [
27
+ { type: 'title', text: '把预算上限变成可复现的场景', level: 2 },
28
+ { type: 'paragraph', html: '公共预算轮次中,有价值的提案往往多于可用资金。这个分配工具要求你输入上限、添加完整提案,并为每项提案提供可比较的票数或收益评分,从而让取舍清晰可见。结果展示的是可行组合,而不是隐藏在单一建议之后的选择。' },
29
+ { type: 'title', text: '选择结果如何计算', level: 2 },
30
+ { type: 'paragraph', html: '模型是0/1背包问题。浏览器比较不超过上限的组合,保留总评分最高的组合。评分相同时依次使用更低成本、更少提案和更早输入顺序。评分是你声明的目标,不是独立的公共价值指标。' },
31
+ { type: 'table', headers: ['字段', '含义', '审核问题'], rows: [['成本', '交付提案所需的完整金额', '所有行是否采用同一口径?'], ['评分', '用户输入的票数或收益值', '这些数值可以比较吗?'], ['预算', '所选提案的最大总成本', '上限是否包含相同成本?'], ['排除', '单独太贵或输给更好组合', '原因是数学上的还是需要政策审核?']] },
32
+ { type: 'title', text: '准备不会误导人的输入', level: 2 },
33
+ { type: 'list', items: ['确认所有提案都适用于同一预算轮次。', '使用完整交付成本,或使用真正独立的阶段。', '记录票数和收益评分的产生方式。', '分别检查依赖关系、维护、法律事项和执行能力。', '把选择结果和排除原因保存为审核记录。'] },
34
+ { type: 'tip', title: '评分是选择的目标', html: '如果评分代表票数,结果会最大化总票数;如果代表收益,就会最大化收益评价。改变评分就改变了问题,因此要把评分方法和结果一起保存。' },
35
+ { type: 'title', text: '阅读已资助和已排除的组合', level: 2 },
36
+ { type: 'paragraph', html: '已资助提案会显示成本、评分和剩余预算。无法放入的提案单独就超过上限。已排除提案可能单独可以放入,但按照选定目标和平局规则,不属于最佳组合。' },
37
+ { type: 'title', text: '限制和后续检查', level: 2 },
38
+ { type: 'paragraph', html: '工具不会判断地区之间的公平性、执行风险、项目冲突或法律资格。为保持精确搜索的响应速度,浏览器模型限制在所选货币下20,000.00。' },
39
+ { type: 'paragraph', html: '高评分组合只有在成本口径和评分方法都清楚时才值得信任。' },
40
+ { type: 'title', text: '把剩余预算变成讨论议题', level: 2 },
41
+ { type: 'paragraph', html: '剩余金额可能表示没有其他提案能改善当前选择,也可能表示遗漏了合资格提案,或预算被有意保留。' },
42
+ { type: 'tip', title: '决定前先检查', html: '把数学选择与正式参与规则以及受影响人群的意见一起阅读。' },
43
+ ], faq, bibliography, howTo, schemas: [softwareApplication, faqSchema, howToSchema] as unknown as Record<string, unknown>[] };
@@ -0,0 +1,11 @@
1
+ import type { ToolDefinition } from '../../types';
2
+ import { participatoryBudgetAllocator } from './entry';
3
+
4
+ export * from './entry';
5
+ export * from './logic';
6
+ export const PARTICIPATORY_BUDGET_ALLOCATOR_TOOL: ToolDefinition = {
7
+ entry: participatoryBudgetAllocator,
8
+ Component: () => import('./component.astro'),
9
+ SEOComponent: () => import('./seo.astro'),
10
+ BibliographyComponent: () => import('./bibliography.astro'),
11
+ };
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { allocateBudget, type AllocationInput } from './logic';
3
+
4
+ const base: AllocationInput = { budget: 10, proposals: [{ name: 'A', cost: 6, score: 9 }, { name: 'B', cost: 4, score: 5 }, { name: 'C', cost: 5, score: 8 }] };
5
+
6
+ describe('participatory budget allocation', () => {
7
+ it('selects a whole proposal combination with the highest score', () => {
8
+ const result = allocateBudget(base);
9
+ expect(result.selected.map((proposal) => proposal.name)).toEqual(['A', 'B']);
10
+ expect(result.totals).toMatchObject({ spent: 10, remaining: 0, score: 14 });
11
+ });
12
+
13
+ it('uses lower cost and then input order for equal scores', () => {
14
+ const result = allocateBudget({ budget: 6, proposals: [{ name: 'First', cost: 6, score: 10 }, { name: 'Second', cost: 5, score: 10 }, { name: 'Third', cost: 5, score: 10 }] });
15
+ expect(result.selected.map((proposal) => proposal.name)).toEqual(['Second']);
16
+ });
17
+
18
+ it('handles empty proposals and no feasible proposal', () => {
19
+ expect(allocateBudget({ budget: 0, proposals: [] }).warnings).toContain('no-proposals');
20
+ const result = allocateBudget({ budget: 3, proposals: [{ name: 'Large', cost: 4, score: 10 }] });
21
+ expect(result.warnings).toContain('no-feasible-proposal');
22
+ expect(result.excluded[0]).toMatchObject({ status: 'too-expensive' });
23
+ });
24
+
25
+ it('supports decimal costs without floating point drift', () => {
26
+ const result = allocateBudget({ budget: 10.5, proposals: [{ name: 'One', cost: 5.25, score: 3 }, { name: 'Two', cost: 5.25, score: 4 }, { name: 'Three', cost: 5.3, score: 6 }] });
27
+ expect(result.totals.spent).toBe(10.5);
28
+ expect(result.selected.map((proposal) => proposal.name)).toEqual(['One', 'Two']);
29
+ });
30
+
31
+ it('rejects invalid fields and impossible budget sizes', () => {
32
+ expect(allocateBudget({ budget: -1, proposals: [] }).errors).toContain('invalid-budget');
33
+ expect(allocateBudget({ budget: 20_000.01, proposals: [] }).errors).toContain('budget-too-large');
34
+ expect(allocateBudget({ budget: 10, proposals: [{ name: '', cost: -2, score: Number.NaN }] }).errors).toEqual(expect.arrayContaining(['missing-name', 'invalid-cost', 'invalid-score']));
35
+ });
36
+
37
+ it('keeps special characters and reports every non-selected reason', () => {
38
+ const result = allocateBudget({ budget: 5, proposals: [{ name: 'Café & youth', cost: 5, score: 1 }, { name: '<Bridge>', cost: 5, score: 2 }] });
39
+ expect(result.selected[0]?.name).toBe('<Bridge>');
40
+ expect(result.excluded[0]?.reason).toContain('remaining budget');
41
+ });
42
+
43
+ it('explains when a proposal exceeds the remaining budget with the selected set', () => {
44
+ const result = allocateBudget({ budget: 5000, proposals: [{ name: 'Community tool library', cost: 4200, score: 74 }, { name: 'Library ramp', cost: 1000, score: 20 }] });
45
+ expect(result.selected.map((proposal) => proposal.name)).toEqual(['Community tool library']);
46
+ expect(result.excluded[0]?.reason).toContain('remaining budget alongside the selected proposals');
47
+ });
48
+ });
@@ -0,0 +1,146 @@
1
+ export interface ProposalInput {
2
+ name: string;
3
+ cost: number;
4
+ score: number;
5
+ }
6
+
7
+ export interface AllocationInput {
8
+ budget: number;
9
+ proposals: ProposalInput[];
10
+ }
11
+
12
+ export type ProposalStatus = 'selected' | 'too-expensive' | 'not-selected' | 'invalid';
13
+
14
+ export interface ProposalResult extends ProposalInput {
15
+ index: number;
16
+ status: ProposalStatus;
17
+ reason: string;
18
+ }
19
+
20
+ export interface AllocationTotals {
21
+ budget: number;
22
+ spent: number;
23
+ remaining: number;
24
+ score: number;
25
+ selectedCount: number;
26
+ }
27
+
28
+ export interface AllocationResult {
29
+ valid: boolean;
30
+ errors: string[];
31
+ warnings: string[];
32
+ selected: ProposalResult[];
33
+ excluded: ProposalResult[];
34
+ allProposals: ProposalResult[];
35
+ totals: AllocationTotals;
36
+ tieBreak: string;
37
+ }
38
+
39
+ interface Candidate {
40
+ indices: number[];
41
+ cost: number;
42
+ score: number;
43
+ }
44
+
45
+ const MAX_BUDGET_CENTS = 2_000_000;
46
+ const TIE_BREAK = 'Maximize total score, then minimize total cost, then prefer fewer proposals and earlier input order.';
47
+
48
+ function isFiniteNonnegative(value: number): boolean {
49
+ return Number.isFinite(value) && value >= 0;
50
+ }
51
+
52
+ function decimalPlaces(value: number): number {
53
+ const text = String(value);
54
+ const decimal = text.split('.')[1];
55
+ return Math.min(decimal?.length ?? 0, 2);
56
+ }
57
+
58
+ function scaleValue(value: number, scale: number): number {
59
+ return Math.round(value * scale);
60
+ }
61
+
62
+ function compareIndices(left: number[], right: number[]): number {
63
+ const length = Math.min(left.length, right.length);
64
+ for (let position = 0; position < length; position += 1) {
65
+ if (left[position] !== right[position]) {
66
+ return left[position]! < right[position]! ? 1 : -1;
67
+ }
68
+ }
69
+ if (left.length === right.length) return 0;
70
+ return left.length < right.length ? 1 : -1;
71
+ }
72
+
73
+ function isBetter(left: Candidate, right: Candidate | undefined): boolean {
74
+ if (!right) return true;
75
+ if (left.score !== right.score) return left.score > right.score;
76
+ if (left.cost !== right.cost) return left.cost < right.cost;
77
+ if (left.indices.length !== right.indices.length) return left.indices.length < right.indices.length;
78
+ return compareIndices(left.indices, right.indices) > 0;
79
+ }
80
+
81
+ function validateInput(input: AllocationInput, budgetUnits: number): string[] {
82
+ const errors: string[] = [];
83
+ if (!isFiniteNonnegative(input.budget)) errors.push('invalid-budget');
84
+ if (input.budget > 20000 || budgetUnits > MAX_BUDGET_CENTS) errors.push('budget-too-large');
85
+ input.proposals.forEach((proposal) => {
86
+ if (!proposal.name.trim()) errors.push('missing-name');
87
+ if (!isFiniteNonnegative(proposal.cost)) errors.push('invalid-cost');
88
+ if (!isFiniteNonnegative(proposal.score)) errors.push('invalid-score');
89
+ });
90
+ return [...new Set(errors)];
91
+ }
92
+
93
+ function createEmptyResult(input: AllocationInput, errors: string[]): AllocationResult {
94
+ const allProposals = input.proposals.map((proposal, index) => ({ ...proposal, index, status: 'invalid' as const, reason: 'invalid-input' }));
95
+ return { valid: false, errors, warnings: [], selected: [], excluded: allProposals, allProposals, totals: { budget: input.budget, spent: 0, remaining: input.budget, score: 0, selectedCount: 0 }, tieBreak: TIE_BREAK };
96
+ }
97
+
98
+ function markProposals(input: AllocationInput, selectedIndices: Set<number>, budget: number, spent: number): ProposalResult[] {
99
+ return input.proposals.map((proposal, index) => {
100
+ let status: ProposalStatus = 'not-selected';
101
+ if (selectedIndices.has(index)) status = 'selected';
102
+ if (!selectedIndices.has(index) && proposal.cost > budget) status = 'too-expensive';
103
+ const reason = getProposalReason(status, proposal.cost + spent > budget);
104
+ return { ...proposal, index, status, reason };
105
+ });
106
+ }
107
+
108
+ function getProposalReason(status: ProposalStatus, exceedsRemainingBudget = false): string {
109
+ if (status === 'selected') return 'Included in the best feasible allocation.';
110
+ if (status === 'too-expensive') return 'Its cost exceeds the available budget on its own.';
111
+ if (status === 'invalid') return 'Correct the proposal before running the model.';
112
+ if (exceedsRemainingBudget) return 'Its cost would exceed the remaining budget alongside the selected proposals.';
113
+ return 'A different feasible combination gives a better score under the tie-break rules.';
114
+ }
115
+
116
+ export function allocateBudget(input: AllocationInput): AllocationResult {
117
+ const scale = 10 ** Math.max(decimalPlaces(input.budget), ...input.proposals.map((proposal) => decimalPlaces(proposal.cost)));
118
+ const budgetCents = scaleValue(input.budget, scale);
119
+ const errors = validateInput(input, budgetCents);
120
+ if (errors.length > 0) return createEmptyResult(input, errors);
121
+ const states: Array<Candidate | undefined> = Array.from({ length: budgetCents + 1 }, () => undefined);
122
+ states[0] = { indices: [], cost: 0, score: 0 };
123
+ input.proposals.forEach((proposal, index) => {
124
+ const cost = scaleValue(proposal.cost, scale);
125
+ for (let capacity = budgetCents; capacity >= cost; capacity -= 1) {
126
+ const previous = states[capacity - cost];
127
+ if (!previous) continue;
128
+ const candidate: Candidate = { indices: [...previous.indices, index], cost: previous.cost + cost, score: previous.score + proposal.score };
129
+ if (isBetter(candidate, states[capacity])) states[capacity] = candidate;
130
+ }
131
+ });
132
+ const best = states.reduce<Candidate | undefined>((current, candidate) => isBetter(candidate ?? { indices: [], cost: 0, score: 0 }, current) ? candidate : current, undefined) ?? { indices: [], cost: 0, score: 0 };
133
+ const selectedIndices = new Set(best.indices);
134
+ const spent = best.cost / scale;
135
+ const allProposals = markProposals(input, selectedIndices, input.budget, spent);
136
+ const selected = allProposals.filter((proposal) => proposal.status === 'selected');
137
+ const excluded = allProposals.filter((proposal) => proposal.status !== 'selected');
138
+ const warnings: string[] = [];
139
+ if (input.proposals.length === 0) warnings.push('no-proposals');
140
+ if (input.proposals.length > 0 && best.indices.length === 0) warnings.push('no-feasible-proposal');
141
+ return { valid: true, errors: [], warnings, selected, excluded, allProposals, totals: { budget: input.budget, spent, remaining: input.budget - spent, score: best.score, selectedCount: selected.length }, tieBreak: TIE_BREAK };
142
+ }
143
+
144
+ export function getExampleInput(): AllocationInput {
145
+ return { budget: 12000, proposals: [{ name: 'Pocket park seating', cost: 3500, score: 86 }, { name: 'Safer school crossing', cost: 6200, score: 100 }, { name: 'Community tool library', cost: 4200, score: 74 }, { name: 'Street tree planting', cost: 2800, score: 64 }, { name: 'Public drinking fountain', cost: 1800, score: 38 }] };
146
+ }